Compare commits

..

5 Commits

Author SHA1 Message Date
Bas950
c44bc69d9f chore: release v1.0.11 2025-12-11 11:08:37 +01:00
Bas van Zanten
7d93ee3a7d feat: add missing files (#1101)
* feat: add missing files

* chore: update readme

* chore: update branch

* chore: remove olds from matrix

* chore: remove dev containers
2025-12-11 11:07:38 +01:00
Florian Metz
79e1984940 feat: add JSON schema for metadata version 1.16 (#1100) 2025-12-11 10:40:15 +01:00
Florian Metz
e6526a3666 feat: monorepo (#1098)
* chore: release v1.1.0

* chore: pd stuff

* chore: release v1.1.1

* feat: add ratelimit environment variables

* chore: release v1.1.2

* chore: improve memory usage

* chore: release v1.1.3

* feat: use sentinels

* chore: release v1.1.4

* chore: update name

* chore: release v1.1.5

* fix(pd): use correct env variable

* chore: release v1.1.6

* wip: docs

* wip: docs

* wip: api

* wip: website

* wip: website

* wip: website

* wip: website

* chore: cleanup

* chore: add lib folder to .gitignore

* chore: remove blank

* wip: csp

* wip: csp

* fix(ci): use correct context

* wip: cd

* wip: cd

* wip: cd

* wip: cd

* wip: cd

* wip: cd

* chore: use correct port

* wip: csp

* wip: csp

* wip: csp

* wip: csp

* chore: update security headers in nuxt.config.ts

* chore: update security headers in nuxt.config.ts

* chore: remove comments

* chore: update npm dependencies

* chore: bump dependencies & add missing

* fix: broken lockfile

* chore: set client host

* chore: update depencies

* style: small layout fixes

* ci: testing

* chore: update CI workflow to trigger on 'monorepo' branch

* chore: use full build image

* chore: test (#1048)

* chore: setup qemu

* chore: remove docs prefix from scripts (#1046)

* style: fix centering (#1050)

* style: render 3 columns (#1049)

* ci: use alpine base (#1051)

* style: make footer follow max width (#1052)

* feat: Add Crowdin configuration for website localization. (#1054)

* chore: change dest in crowdin

* chore: improve crowdin pr

* chore: improve crowdin pr

* chore: lint

* chore: update language folder

* chore: update crowdin configuration to skip untranslated strings and files

* chore: add crowdin badge

* chore: release v1.0.1

* chore: release v1.1.7

* chore: fix build

* chore: release v1.1.8

* chore: release v1.0.2

* chore: fix docker

* chore: release v1.1.9

* chore: release v1.0.3

* feat: add more api endpoints (#1059)

* chore: worked on the api and lint

* chore: small fixes

* chore: uhm I think this sort is broken

* chore: worked on the api and lint

* chore: small fixes

* chore: uhm I think this sort is broken

* feat: heartbeat

* chore: add prettier ignore

* feat: websocket

* chore: update tsconfig

* chore: lint

* chore: dont require unused fields

* chore: use djs rest

* fix: websocket

* chore: v5

* chore: fix build

---------

Co-authored-by: Florian Metz <me@timeraa.dev>

* chore: wip api

* chore: wip api

* chore: release v0.0.1

* chore: deploy on tag

* chore: release v0.0.1

* chore(api): remove old sentry tracing

* chore: release v0.0.2

* chore: release v0.0.2

* feat(api-master): add logs

* chore: release v0.0.3

* chore: bump dep

* feat: add feature flags

* chore: release v0.0.3

* feat: metrics?

* chore: release v0.0.4

* chore: update npm dependencies

* feat: add session-keep-alive

* chore: fix build

* chore: release v0.0.5

* chore: release v0.0.4

* chore: update arktype

* chore: release v0.0.6

* chore: hash the key

* chore: release v0.0.7

* chore: release v0.0.5

* chore: revert redis stuff

* chore: release v0.0.6

* chore: release v0.0.8

* feat(api-master): add metrics

* chore: release v0.0.7

* chore: update lockfile

* chore: release v0.0.8

* chore: idk kek

* chore: iodk

* chore: release v0.0.8

* feat: use scan

* chore: release v0.0.9

* chore: skip clearOldSesssions if another in progress

* chore: release v0.0.10

* chore: optimize session cleanup with batch deletion

* chore: release v0.0.11

* chore: move some code

* chore: release v0.0.12

* chore: add timeout to headless session deletion

* chore: release v0.0.13

* chore: add p-limit dependency for session cleanup

* chore: release v0.0.14

* chore: always return the key

* chore: release v0.0.15

* chore: add p-limit dependency for session cleanup

* chore: release v0.0.16

* chore: why does it not abort

* chore: release v0.0.17

* chore: fix time

* chore: release v0.0.18

* chore: add reason

* chore: release v0.0.19

* feat: use scienceId

* chore: release v0.0.20

* chore: release v0.0.9

* chore: update log

* chore: release v0.0.20

* chore: use ky

* chore: release v0.0.21

* chore: 202 on disabled flag

* chore: release v0.0.10

* chore: test

* chore: release v0.0.22

* chore: release v0.0.11

* chore: test

* chore: test

* chore: release v0.0.12

* chore: test

* chore: release v0.0.23

* chore: update hash

* chore: release v0.0.24

* chore: release v0.0.13

* feat: update tracing (#1067)

* chore: release v0.0.14

* chore: release v0.0.25

* fix: store ip data in postgres

* chore: lint

* chore: release v0.0.26

* chore: reduce batch size

* chore: release v0.0.27

* chore: disable ip stuff for now

* chore: release v0.0.28

* chore: reduce memory

* chore: release v0.0.29

* chore: release v0.0.29

* chore: release v0.0.29

* chore: small updates

* chore: release v0.0.30

* chore: optimize active presence gauge update with concurrency limit

* chore: release v0.0.31

* chore: some improvements

* chore: forgot to save

* chore: release v0.0.32

* chore: some testing

* chore: release v0.0.33

* chore: scan count config

* chore: release v0.0.34

* feat: schema v1.11

* chore: release v1.0.4

* feat: use prom-client

* chore: release v0.0.35

* chore: scan keys instead

* chore: release v0.0.36

* feat: ip data

* chore: release v0.0.37

* feat: discord-bot (#1069)

* feat: discord-bot

* feat: final things

* chore: add to matrix

* feat: add sentry

* chore: move some things

* chore: update version

* chore: release v1.0.0

* fix(discord-bot): fixes credits

* chore: release v1.0.1

* feat(discord-bot): add beta command

* chore: release v1.0.2

* chore: un-ingore config files

* chore: lint

* fix(discord-bot): update developer roles constant (#1070)

* chore: disable commitlint

* fix(discord-bot): update developer roles constant

* Update commit-msg

* chore: release v1.0.3

* Update presence.ts (#1072)

* fix: get presence list correctly

* chore: release v1.0.4

* chore: change info message

* chore: release v1.0.5

* fix(api): metadata types

* chore: release v0.0.15

* chore: fix type

* chore: release v0.0.16

* fix: add dbName

* chore: release v0.0.17

* feat: fix presence endpoints

* chore: release v0.0.18

* fix: show displayName

* chore: release v1.0.6

* feat: extension version gauge (#1074)

* feat: extension version gauge

* chore: lint

* feat: new schema version

* chore: release v1.0.5

* chore: release v0.0.38

* chore: remove ip lookup

* chore: lint

* chore: release v0.0.39

* chore: add environment variable to disable

* chore: release v0.0.19

* chore(bot): rename presence to activity (#1077)

* chore: release v1.0.7

* feat(schema-server): add v1.13

* chore: release v1.0.6

* chore: add mobile and update descriptions

* chore: release v1.0.7

* chore: lint

* chore: release v1.0.8

* chore: add Social Media Manager role and color to constants (#1084)

* chore: add Social Media Manager role and color to constants

* chore: bump version

* feat: v1.14 of schema

* chore: release v1.0.9

* feat: schema 1.15

* chore: release v1.0.10

* feat: add file extension

* chore: release v1.2.0

* fix: return image directly

* chore: release v1.2.1

* chore: remove cache headers

* chore: release v1.2.2

* chore: set more headers

* chore: release v1.2.3

* fix: ttl

* chore: release v1.2.4

* chore: strip monorepo down to pd and schema-server only

Remove all apps except pd and schema-server to create a minimal repository.
Deleted apps: api-master, api-worker, discord-bot, docs, website
Deleted packages directory and all monorepo configuration files
Removed unnecessary config files: eslint, prettier, commitlint, vitest, etc.

* docs: update README to be more user-friendly and generic

- Rewritten to focus on what PreMiD is rather than repository structure
- Added friendly introduction to PreMiD's features and capabilities
- Highlighted the open source Presences repository at github.com/PreMiD/Presences
- Removed monorepo-specific content and development instructions
- Added community section with helpful links
- Overall more welcoming tone for users and contributors

* chore: restore TypeScript build configuration and update dependencies

- Added back tsconfig.base.json and tsconfig.app.json needed for builds
- Updated both pd and schema-server tsconfig files to exclude test files
- Installed TypeScript and @types/node dependencies for both apps
- Updated README to include feedback.premid.app link
- Verified both apps build successfully

The apps can now be built with: npx tsc -b tsconfig.app.json

* docs: update README to use Activities terminology

- Changed "Presences" to "Activities" throughout
- Updated repository link to github.com/PreMiD/Activities
- Updated examples: YouTube, Disney+, Netflix, Twitch
- Noted that Spotify has native Discord support

* docs: clarify open source status and installation process

- Added Contributing section explaining that Activities are open source but the main app is not
- Clarified this helps the small team manage maintenance burden
- Updated "install PreMiD" to "add the browser extension" for accuracy
- Provided clear ways to contribute: Activities, translations, feedback

* docs: clarify extension-only model and development speed

- Changed "application and extension" to just "extension"
- Updated reasoning: allows team to move fast and iterate quickly
- No separate application exists, only the browser extension

* docs: add Discord server link

* docs: add banner image to README

* docs: move logo inline with heading

---------

Co-authored-by: Bas950 <me@bas950.com>
Co-authored-by: veryCrunchy <verycrunchydev@gmail.com>
Co-authored-by: veryCrunchy <me@verycrunchy.dev>
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-02 18:26:18 +01:00
Florian Metz
f9a976ef1d Delete .github/dependabot.yml 2024-02-16 09:52:17 +01:00
168 changed files with 1529 additions and 22691 deletions

View File

@@ -1 +0,0 @@
FROM mcr.microsoft.com/devcontainers/base:bullseye

View File

@@ -1,24 +0,0 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-docker-compose
{
"name": "PreMiD",
"dockerComposeFile": ["docker-compose.yml"],
"service": "app",
"workspaceFolder": "/workspaces",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "lts",
"nvmVersion": "latest"
},
"ghcr.io/joshuanianji/devcontainer-features/mount-pnpm-store:1": {},
"ghcr.io/dhoeric/features/act:1": {}
},
"overrideFeatureInstallOrder": ["ghcr.io/devcontainers/features/node:1", "ghcr.io/joshuanianji/devcontainer-features/mount-pnpm-store:1"],
"postCreateCommand": "pnpm i --frozen-lockfile",
"customizations": {
"vscode": {
"extensions": ["Gruntfuggly.todo-tree", "YoavBls.pretty-ts-errors", "EditorConfig.EditorConfig", "DeepScan.vscode-deepscan", "esbenp.prettier-vscode"]
}
},
"shutdownAction": "stopCompose"
}

View File

@@ -1,32 +0,0 @@
version: "3.8"
services:
# Update this to the name of the service you want to work with in your docker-compose.yml file
app:
build:
context: .
dockerfile: Dockerfile
# Uncomment if you want to override the service's Dockerfile to one in the .devcontainer
# folder. Note that the path of the Dockerfile and context is relative to the *primary*
# docker-compose.yml file (the first in the devcontainer.json "dockerComposeFile"
# array). The sample below assumes your primary file is in the root of your project.
#
# build:
# context: .
# dockerfile: .devcontainer/Dockerfile
volumes:
# Update this to wherever you want VS Code to mount the folder of your project
- ..:/workspaces:cached
# Uncomment the next four lines if you will use a ptrace-based debugger like C++, Go, and Rust.
# cap_add:
# - SYS_PTRACE
# security_opt:
# - seccomp:unconfined
# Overrides default command so things don't shut down after the process ends.
command: /bin/sh -c "while sleep 1000; do :; done"
redis:
image: redis
ports:
- "6379:6379"

View File

@@ -2,7 +2,7 @@ name: CD
on:
push:
branches:
- monorepo
- main
tags:
- "*"
permissions:
@@ -17,10 +17,6 @@ jobs:
target:
- pd
- schema-server
- website
- api-worker
- api-master
- discord-bot
steps:
- name: Checkout Repository
uses: actions/checkout@v4

View File

@@ -2,7 +2,7 @@ name: Build, Lint and Test
on:
push:
branches:
- monorepo
- main
pull_request:
jobs:
build:
@@ -45,10 +45,6 @@ jobs:
target:
- pd
- schema-server
- api-worker
- api-master
- website
- discord-bot
steps:
- name: Checkout Repository
uses: actions/checkout@v4

View File

@@ -1,35 +1,44 @@
<img src="https://cdn.rcd.gg/PreMiD.png" width="150px" />
<img width="1280" height="800" alt="Chrome Global Screenshots" src="https://github.com/user-attachments/assets/1ea21f91-7499-43de-8b9a-3344d1c0fe48" />
# PreMiD
# <img src="https://cdn.rcd.gg/PreMiD.png" height="40px" /> PreMiD
[![Open in Dev Containers](https://img.shields.io/static/v1?label=Dev%20Containers&message=Open&color=blue&logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/PreMiD/PreMiD)
[![Crowdin](https://badges.crowdin.net/premid/localized.svg)](https://crowdin.com/project/premid)
This is the monorepo for PreMiD. PreMiD is a simple, configurable utility that allows you to show what you're watching/listening to on your Discord profile.
PreMiD is a simple, configurable utility that lets you show what you're doing on the web in your Discord profile. Whether you're watching videos, listening to music, browsing your favorite sites, or playing browser games, PreMiD helps you share your online activities with your friends through Discord's Rich Presence feature.
## Getting Started
**If you are a user looking to install PreMiD, please visit the [official website](https://premid.app).**
**Looking to use PreMiD?** Head over to our [official website](https://premid.app) to add the browser extension!
If you are a developer looking to contribute to PreMiD, read along.
**Want to create your own Activity?** All of our community-created activities are open source and available at [github.com/PreMiD/Activities](https://github.com/PreMiD/Activities). We'd love to see what you create!
## Table of Contents
## Features
- [Packages](#packages)
- [License](#license)
- 🎵 Show what you're listening to on YouTube and more (Spotify has native Discord support)
- 📺 Display what you're watching on Netflix, Disney+, Twitch, and hundreds of other sites
- 🎮 Share your browser game activity with friends
- ✨ Fully customizable with thousands of user-created Activities
- 🌍 Available in multiple languages thanks to our amazing community translators
## Packages
## Community
This monorepo is split into multiple packages / projects. Here's a list of them:
PreMiD is built by the community, for the community. Join us and help make PreMiD even better!
- [apps/api](apps/api) - The API for PreMiD.
- [apps/website](apps/website) - The website for PreMiD.
- [apps/docs](apps/docs) - The official documentation for PreMiD.
- [apps/pd](apps/pd/README.md) - A simple url shortener service to shorten urls longer than 256 characters.
- [apps/schema-server](apps/schema-server) - Simple Schema server for the Presence manifest.
- [packages/db](packages/db) - Database schema for PreMiD.
- **Activities Repository**: [github.com/PreMiD/Activities](https://github.com/PreMiD/Activities)
- **Documentation**: [docs.premid.app](https://docs.premid.app)
- **Discord Server**: [discord.premid.app](https://discord.premid.app)
- **Feedback & Bug Reports**: [feedback.premid.app](https://feedback.premid.app)
## Development
## Contributing
We love community contributions! While **PreMiD's Activities are fully open source** (the code that makes websites show up on your profile), the PreMiD extension is not currently open source. This decision allows our small team to move fast and iterate quickly to deliver the best experience possible.
You can contribute by:
- Creating new Activities at [github.com/PreMiD/Activities](https://github.com/PreMiD/Activities)
- Helping translate PreMiD on [Crowdin](https://crowdin.com/project/premid)
- Reporting bugs and suggesting features at [feedback.premid.app](https://feedback.premid.app)
- Supporting the project and spreading the word!
### Release

View File

@@ -1,10 +0,0 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dbCredentials: {
url: "postgresql://metrics:metrics@localhost:5432/metrics",
},
dialect: "postgresql",
schema: "./src/db.ts",
out: "./drizzle",
});

View File

@@ -1,9 +0,0 @@
CREATE TABLE IF NOT EXISTS "online_users_ip_data" (
"uuid" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"ip" varchar(45) NOT NULL,
"country" varchar(2) NOT NULL,
"latitude" numeric(10, 8) NOT NULL,
"longitude" numeric(11, 8) NOT NULL,
"name" varchar(255),
"timestamp" timestamp DEFAULT now()
);

View File

@@ -1,2 +0,0 @@
CREATE INDEX IF NOT EXISTS "idx_online_users_uuid" ON "online_users_ip_data" USING btree ("uuid");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "idx_online_users_timestamp" ON "online_users_ip_data" USING btree ("timestamp");

View File

@@ -1 +0,0 @@
ALTER TABLE "online_users_ip_data" ALTER COLUMN "timestamp" SET DATA TYPE timestamp with time zone;

View File

@@ -1,2 +0,0 @@
ALTER TABLE "online_users_ip_data" ADD COLUMN "presences" jsonb DEFAULT '[]' NOT NULL;--> statement-breakpoint
ALTER TABLE "online_users_ip_data" DROP COLUMN IF EXISTS "name";

View File

@@ -1 +0,0 @@
ALTER TABLE "online_users_ip_data" ADD COLUMN "sessions" integer DEFAULT 0 NOT NULL;

View File

@@ -1,70 +0,0 @@
{
"id": "e29a6708-01f1-455a-b345-63dac1e124dc",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.online_users_ip_data": {
"name": "online_users_ip_data",
"schema": "",
"columns": {
"uuid": {
"name": "uuid",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"ip": {
"name": "ip",
"type": "varchar(45)",
"primaryKey": false,
"notNull": true
},
"country": {
"name": "country",
"type": "varchar(2)",
"primaryKey": false,
"notNull": true
},
"latitude": {
"name": "latitude",
"type": "numeric(10, 8)",
"primaryKey": false,
"notNull": true
},
"longitude": {
"name": "longitude",
"type": "numeric(11, 8)",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"timestamp": {
"name": "timestamp",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"schemas": {},
"sequences": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -1,101 +0,0 @@
{
"id": "4aa32a8e-f573-43b9-976a-2d078a0df0ea",
"prevId": "e29a6708-01f1-455a-b345-63dac1e124dc",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.online_users_ip_data": {
"name": "online_users_ip_data",
"schema": "",
"columns": {
"uuid": {
"name": "uuid",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"ip": {
"name": "ip",
"type": "varchar(45)",
"primaryKey": false,
"notNull": true
},
"country": {
"name": "country",
"type": "varchar(2)",
"primaryKey": false,
"notNull": true
},
"latitude": {
"name": "latitude",
"type": "numeric(10, 8)",
"primaryKey": false,
"notNull": true
},
"longitude": {
"name": "longitude",
"type": "numeric(11, 8)",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"timestamp": {
"name": "timestamp",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"idx_online_users_uuid": {
"name": "idx_online_users_uuid",
"columns": [
{
"expression": "uuid",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_online_users_timestamp": {
"name": "idx_online_users_timestamp",
"columns": [
{
"expression": "timestamp",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"schemas": {},
"sequences": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -1,101 +0,0 @@
{
"id": "c1b8dbed-b232-4d66-9e74-b9af333095bc",
"prevId": "4aa32a8e-f573-43b9-976a-2d078a0df0ea",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.online_users_ip_data": {
"name": "online_users_ip_data",
"schema": "",
"columns": {
"uuid": {
"name": "uuid",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"ip": {
"name": "ip",
"type": "varchar(45)",
"primaryKey": false,
"notNull": true
},
"country": {
"name": "country",
"type": "varchar(2)",
"primaryKey": false,
"notNull": true
},
"latitude": {
"name": "latitude",
"type": "numeric(10, 8)",
"primaryKey": false,
"notNull": true
},
"longitude": {
"name": "longitude",
"type": "numeric(11, 8)",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"timestamp": {
"name": "timestamp",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"idx_online_users_uuid": {
"name": "idx_online_users_uuid",
"columns": [
{
"expression": "uuid",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_online_users_timestamp": {
"name": "idx_online_users_timestamp",
"columns": [
{
"expression": "timestamp",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"schemas": {},
"sequences": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -1,102 +0,0 @@
{
"id": "e409a4d0-f698-484a-b412-38966a7b3a19",
"prevId": "c1b8dbed-b232-4d66-9e74-b9af333095bc",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.online_users_ip_data": {
"name": "online_users_ip_data",
"schema": "",
"columns": {
"uuid": {
"name": "uuid",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"ip": {
"name": "ip",
"type": "varchar(45)",
"primaryKey": false,
"notNull": true
},
"country": {
"name": "country",
"type": "varchar(2)",
"primaryKey": false,
"notNull": true
},
"latitude": {
"name": "latitude",
"type": "numeric(10, 8)",
"primaryKey": false,
"notNull": true
},
"longitude": {
"name": "longitude",
"type": "numeric(11, 8)",
"primaryKey": false,
"notNull": true
},
"presences": {
"name": "presences",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'[]'"
},
"timestamp": {
"name": "timestamp",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"idx_online_users_uuid": {
"name": "idx_online_users_uuid",
"columns": [
{
"expression": "uuid",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_online_users_timestamp": {
"name": "idx_online_users_timestamp",
"columns": [
{
"expression": "timestamp",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"schemas": {},
"sequences": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -1,109 +0,0 @@
{
"id": "179435b5-dc15-4a42-9539-c3f336699d63",
"prevId": "e409a4d0-f698-484a-b412-38966a7b3a19",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.online_users_ip_data": {
"name": "online_users_ip_data",
"schema": "",
"columns": {
"uuid": {
"name": "uuid",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"ip": {
"name": "ip",
"type": "varchar(45)",
"primaryKey": false,
"notNull": true
},
"country": {
"name": "country",
"type": "varchar(2)",
"primaryKey": false,
"notNull": true
},
"latitude": {
"name": "latitude",
"type": "numeric(10, 8)",
"primaryKey": false,
"notNull": true
},
"longitude": {
"name": "longitude",
"type": "numeric(11, 8)",
"primaryKey": false,
"notNull": true
},
"sessions": {
"name": "sessions",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"presences": {
"name": "presences",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'[]'"
},
"timestamp": {
"name": "timestamp",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"idx_online_users_uuid": {
"name": "idx_online_users_uuid",
"columns": [
{
"expression": "uuid",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_online_users_timestamp": {
"name": "idx_online_users_timestamp",
"columns": [
{
"expression": "timestamp",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"schemas": {},
"sequences": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -1,41 +0,0 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1726516195146,
"tag": "0000_flippant_marrow",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1726516348344,
"tag": "0001_white_lifeguard",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1726516660134,
"tag": "0002_new_darkhawk",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1726517073510,
"tag": "0003_narrow_mastermind",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1726517405363,
"tag": "0004_tiresome_puff_adder",
"breakpoints": true
}
]
}

View File

@@ -1,16 +0,0 @@
declare module "ip-location-api" {
export function lookup(ip: string): Promise<{
latitude: number;
longitude: number;
country: string;
} | null>;
export function updateDb(options: { fields?: string[]; dataDir?: string; tmpDataDir?: string; smallMemory?: boolean; autoUpdate?: string }): Promise<void>;
export function reload(options: { fields?: string[]; dataDir?: string; tmpDataDir?: string; smallMemory?: boolean; autoUpdate?: string }): Promise<void>;
}
declare namespace NodeJS {
export interface ProcessEnv {
METRICS_DATABASE_URL?: string;
}
}

View File

@@ -1,37 +0,0 @@
{
"name": "@premid/api-master",
"type": "module",
"version": "0.0.37",
"private": true,
"description": "PreMiD's api master",
"license": "MPL-2.0",
"main": "dist/index.js",
"files": [
"dist"
],
"scripts": {
"start": "node --enable-source-maps .",
"dev": "node --watch --env-file .env --enable-source-maps .",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:update": "pnpm db:generate && pnpm db:migrate",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@envelop/sentry": "^9.0.0",
"@sentry/node": "^8.17.0",
"cron": "^3.1.7",
"debug": "^4.3.6",
"drizzle-orm": "^0.33.0",
"ioredis": "^5.3.2",
"ip-location-api": "^2.0.1",
"ky": "^1.7.2",
"p-limit": "^6.1.0",
"postgres": "^3.4.4",
"prom-client": "^15.1.3"
},
"devDependencies": {
"@types/debug": "^4.1.12",
"drizzle-kit": "^0.24.2"
}
}

View File

@@ -1,27 +0,0 @@
import process from "node:process";
import { decimal, index, integer, jsonb, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
// Define the schema
export const onlineUsersIpData = pgTable("online_users_ip_data", {
uuid: uuid("uuid").primaryKey().defaultRandom(),
ip: varchar("ip", { length: 45 }).notNull(),
country: varchar("country", { length: 2 }).notNull(),
latitude: decimal("latitude", { precision: 10, scale: 8 }).notNull(),
longitude: decimal("longitude", { precision: 11, scale: 8 }).notNull(),
sessions: integer("sessions").notNull().default(0),
presences: jsonb("presences").notNull().default("[]").$type<string[]>(),
timestamp: timestamp("timestamp", { withTimezone: true }).defaultNow(),
}, table => ({
idxOnlineUsersUuid: index("idx_online_users_uuid").on(table.uuid),
idxOnlineUsersTimestamp: index("idx_online_users_timestamp").on(table.timestamp),
}));
if (!process.env.METRICS_DATABASE_URL) {
throw new Error("METRICS_DATABASE_URL is not set");
}
export const sql = postgres(process.env.METRICS_DATABASE_URL);
export const db = drizzle(sql);

View File

@@ -1,10 +0,0 @@
import { lt, sql } from "drizzle-orm";
import { db, onlineUsersIpData } from "../db.js";
import { mainLog } from "../index.js";
export async function cleanupOldUserData(retentionDays: number) {
mainLog("Cleaning up old user ip data");
const interval = `'${retentionDays} days'`;
await db.delete(onlineUsersIpData)
.where(lt(onlineUsersIpData.timestamp, sql`now() - interval ${sql.raw(interval)}`));
}

View File

@@ -1,100 +0,0 @@
import pLimit from "p-limit";
import ky, { HTTPError, TimeoutError } from "ky";
import { mainLog, redis } from "../index.js";
let inProgress = false;
export async function clearOldSessions() {
if (inProgress) {
mainLog("Session cleanup already in progress");
return;
}
inProgress = true;
const now = Date.now();
const pattern = "pmd-api.sessions.*";
let cursor = "0";
let totalSessions = 0;
let cleared = 0;
const batchSize = 100;
let keysToDelete: string[] = [];
mainLog("Starting session cleanup");
const limit = pLimit(100); // Create a limit of 100 concurrent operations
do {
const [newCursor, keys] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 1000); //* Use SCAN with COUNT for memory efficiency
cursor = newCursor;
totalSessions += keys.length;
const deletePromises: Promise<string>[] = [];
for (const key of keys) {
const session = await redis.hgetall(key) as unknown as {
token: string;
session: string;
lastUpdated: number;
};
if (now - session.lastUpdated < 30000)
continue;
deletePromises.push(limit(() => deleteSession(session, key)));
}
const results = await Promise.allSettled(deletePromises);
results.forEach((result) => {
if (result.status === "fulfilled" && result.value) {
keysToDelete.push(result.value);
cleared++;
}
});
if (keysToDelete.length >= batchSize) {
await redis.del(...keysToDelete);
keysToDelete = [];
}
} while (cursor !== "0");
if (keysToDelete.length > 0) {
await redis.del(...keysToDelete);
}
if (totalSessions === 0) {
mainLog("No sessions to clear");
}
else {
mainLog(`Checked ${totalSessions} sessions, cleared ${cleared}`);
}
inProgress = false;
}
async function deleteSession(session: { token: string; session: string }, key: string): Promise<string> {
try {
await ky.post("https://discord.com/api/v10/users/@me/headless-sessions/delete", {
json: {
token: session.session,
},
headers: {
Authorization: `Bearer ${session.token}`,
},
retry: 3,
timeout: 5000,
});
}
catch (error) {
if (error instanceof TimeoutError) {
mainLog(`Session deletion aborted due to timeout for key ${key}`);
}
else if (error instanceof HTTPError) {
mainLog(`Failed to delete session for key ${key}: [${error.name}] ${error.message} ${JSON.stringify(await error.response.json())}`);
}
else {
mainLog(`Failed to delete session for key ${key}: Unknown error`);
}
}
return key;
}

View File

@@ -1,30 +0,0 @@
import { hostname } from "node:os";
import process from "node:process";
import { Redis } from "ioredis";
/* c8 ignore start */
export default function createRedis(): Redis {
const redis = new Redis({
connectionName: `api-master-${hostname()}-${process.pid.toString()}`,
lazyConnect: true,
name: "mymaster",
sentinels: process.env.REDIS_SENTINELS?.split(",").map(s => ({
host: s,
port: 26_379,
})),
});
/* c8 ignore next 3 */
redis.on("error", (error) => {
console.error("Redis error", error);
});
/* c8 ignore next 4 */
redis.on("connect", () => {
// eslint-disable-next-line no-console
console.log("Redis connected");
});
return redis;
}

View File

@@ -1,40 +0,0 @@
import type { InferInsertModel } from "drizzle-orm";
import { db, onlineUsersIpData } from "../db.js";
import { lookupIp } from "./lookupIp.js";
const batchSize = 1000;
export async function insertIpData(
data: Map<string, {
presences: string[];
sessions: number;
}>,
) {
const timestamp = new Date();
const list = [...data.keys()];
//* Split into batches of batchSize
for (let i = 0; i < list.length; i += batchSize) {
const batch = list.slice(i, i + batchSize);
const mapped = await Promise.all(batch.map(async (ip) => {
const parsed = await lookupIp(ip);
if (parsed) {
const { presences, sessions } = data.get(ip)!;
return {
ip,
country: parsed.country,
latitude: parsed.latitude.toString(),
longitude: parsed.longitude.toString(),
presences,
sessions,
timestamp,
} satisfies InferInsertModel<typeof onlineUsersIpData>;
}
}));
const toInsert = mapped.filter(Boolean) as InferInsertModel<typeof onlineUsersIpData>[];
if (toInsert.length > 0) {
await db.insert(onlineUsersIpData).values(toInsert);
}
}
}

View File

@@ -1,46 +0,0 @@
import { join } from "node:path";
import process from "node:process";
import { lookup, reload } from "ip-location-api";
import { mainLog } from "../index.js";
const fields = ["latitude", "longitude", "country"];
const dataDir = join(process.cwd(), "data");
const tmpDataDir = join(process.cwd(), "tmp");
const smallMemory = true;
let initialized = false;
export async function lookupIp(ip: string): Promise<{ latitude: number; longitude: number; country: string } | undefined> {
if (!initialized) {
reloadIpLocationApi();
return undefined;
}
try {
return await lookup(ip) ?? undefined;
}
catch {
return undefined;
}
}
let reloading: Promise<void> | undefined;
let log: debug.Debugger | undefined;
export async function reloadIpLocationApi() {
log ??= mainLog.extend("IP-Location-API");
if (reloading)
return reloading;
reloading = new Promise((resolve, reject) => {
log?.("Reloading IP location API");
reload({ fields, dataDir, tmpDataDir, smallMemory, autoUpdate: "0 9 * * *" }).then(() => {
log?.("IP location API reloaded");
initialized = true;
reloading = undefined;
resolve();
}).catch(reject);
});
return reloading;
}

View File

@@ -1,25 +0,0 @@
import http from "node:http";
import { mainLog } from "../index.js";
import { register } from "../tracing.js";
export function setupServer() {
const server = http.createServer(async (req, res) => {
//* If it's a head request, just return 200
if (req.method === "HEAD")
return res.writeHead(200).end();
//* If it's a favicon request, just return 404
if (req.url === "/favicon.ico")
return res.writeHead(404).end();
//* Basic routing logic
res.writeHead(200, { "Content-Type": "text/plain" });
res.end(await register.metrics());
});
server.listen(9464, () => {
mainLog("Server running");
});
return server;
}

View File

@@ -1,77 +0,0 @@
import process from "node:process";
import pLimit from "p-limit";
import type { Gauge } from "prom-client";
import { mainLog, redis } from "../index.js";
import { insertIpData } from "./insertIpData.js";
export const updateActivePresenceGaugeLimit = pLimit(1);
let log: debug.Debugger | undefined;
const scanCount = Number.parseInt(process.env.SCAN_COUNT || "1000", 10);
export async function updateActivePresenceGauge(gauge: Gauge) {
await updateActivePresenceGaugeLimit(async () => {
log ??= mainLog.extend("Heartbeat-Updates");
log?.("Starting active presence gauge update");
const pattern = "pmd-api.heartbeatUpdates.*";
let cursor: string = "0";
const serviceCounts = new Map<string, number>();
const ips = new Map<string, {
presences: Set<string>;
sessions: number;
}>();
do {
const [newCursor, keys] = await redis.scan(cursor, "MATCH", pattern, "COUNT", scanCount);
cursor = newCursor;
//* Use pipelining for batch Redis operations
const pipeline = redis.pipeline();
keys.forEach(key => pipeline.hmget(key, "service", "version", "ip_address"));
const hashes = await pipeline.exec();
if (!hashes) {
log?.("No hashes found");
return;
}
hashes.forEach(([err, hash]) => {
if (err || !Array.isArray(hash))
return;
const [service, version, ip] = hash;
const serviceVersion = service && version ? `${service}:${version}` : "none";
serviceCounts.set(serviceVersion, (serviceCounts.get(serviceVersion) || 0) + 1);
if (ip) {
const ipData = ips.get(ip) || { presences: new Set(), sessions: 0 };
if (serviceVersion !== "none")
ipData.presences.add(serviceVersion);
ipData.sessions++;
ips.set(ip, ipData);
}
});
} while (cursor !== "0");
log?.("Updating active presence gauge");
//* Batch update the gauge
gauge.reset();
for (const [serviceVersion, count] of serviceCounts) {
const [presence_name, version] = serviceVersion.split(":");
gauge.set({ presence_name, version }, count);
}
//* Convert IP data for insertion
const ipDataForInsertion = new Map(
Array.from(ips, ([ip, data]) => [ip, {
presences: Array.from(data.presences),
sessions: data.sessions,
}]),
);
await insertIpData(ipDataForInsertion);
log?.("Active presence gauge update completed");
});
}

View File

@@ -1,44 +0,0 @@
import process from "node:process";
import { CronJob } from "cron";
import debug from "debug";
import { clearOldSessions } from "./functions/clearOldSessions.js";
import createRedis from "./functions/createRedis.js";
import "./tracing.js";
import { reloadIpLocationApi } from "./functions/lookupIp.js";
import { cleanupOldUserData } from "./functions/cleanupOldUserData.js";
import { setupServer } from "./functions/setupServer.js";
export const redis = createRedis();
export const server = setupServer();
export const mainLog = debug("api-master");
debug("Starting cron jobs");
void reloadIpLocationApi();
void new CronJob(
// Every 5 seconds
"*/5 * * * * *",
() => {
if (process.env.DISABLE_CLEAR_OLD_SESSIONS !== "true") {
clearOldSessions();
}
},
undefined,
true,
);
void new CronJob(
// Every day at 1am
"0 1 * * *",
() => {
cleanupOldUserData(14); // Keep 14 days of data
},
undefined,
true,
undefined,
undefined,
true,
);

View File

@@ -1,41 +0,0 @@
import process from "node:process";
import { Counter, Gauge, Registry, collectDefaultMetrics } from "prom-client";
import { updateActivePresenceGauge, updateActivePresenceGaugeLimit } from "./functions/updateActivePresenceGauge.js";
import { redis } from "./index.js";
const scanCount = Number.parseInt(process.env.SCAN_COUNT || "1000", 10);
export const register = new Registry();
collectDefaultMetrics({ register });
export const activeSessionsCounter = new Counter({
name: "active_sessions",
help: "Number of active sessions",
async collect() {
this.reset();
let length = 0;
let cursor = "0";
do {
const reply = await redis.scan(cursor, "MATCH", "pmd-api.sessions.*", "COUNT", scanCount);
cursor = reply[0];
length += reply[1].length;
} while (cursor !== "0");
this.inc(length);
},
});
export const activePresencesCounter = new Gauge({
name: "active_presences",
help: "Number of active presences",
labelNames: ["presence_name", "version"],
async collect() {
if (process.env.DISABLE_ACTIVE_PRESENCE_GAUGE !== "true") {
this.reset();
updateActivePresenceGaugeLimit.clearQueue();
await updateActivePresenceGauge(this);
}
},
});
register.registerMetric(activeSessionsCounter);
register.registerMetric(activePresencesCounter);

View File

@@ -1,10 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"types": ["./environment.d.ts"],
"outDir": "dist"
},
"include": ["src/**/*"]
}

View File

@@ -1,8 +0,0 @@
{
"extends": "./tsconfig.app.json",
"compilerOptions": {
"rootDir": ".",
"noEmit": true
},
"include": ["environment.d.ts", "src", "codegen.ts"]
}

View File

@@ -1 +0,0 @@
generated

View File

@@ -1,26 +0,0 @@
import type { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
generates: {
"dist/generated/schema-v5.graphql": {
plugins: ["schema-ast"],
schema: "src/graphql/schema/v5/**/*.gql",
},
"src/generated/graphql-v5.ts": {
config: {
scalars: {
StringOrStringArray: "string | string[]",
},
},
plugins: ["typescript", "typescript-resolvers"],
schema: "src/graphql/schema/v5/**/*.gql",
},
"src/generated/schema-v5.graphql": {
plugins: ["schema-ast"],
schema: "src/graphql/schema/v5/**/*.gql",
},
},
overwrite: true,
};
export default config;

View File

@@ -1,7 +0,0 @@
declare namespace NodeJS {
export interface ProcessEnv {
NODE_ENV?: "development" | "production" | "test";
DATABASE_URL?: string;
SESSION_KEEP_ALIVE_INTERVAL?: string;
}
}

View File

@@ -1,49 +0,0 @@
{
"name": "@premid/api-worker",
"type": "module",
"version": "0.0.14",
"private": true,
"description": "PreMiD's api",
"license": "MPL-2.0",
"main": "dist/index.js",
"files": [
"dist"
],
"scripts": {
"start": "node --enable-source-maps .",
"dev": "node --watch --env-file .env --enable-source-maps .",
"build": "pnpm codegen",
"codegen": "graphql-codegen --config codegen.ts"
},
"dependencies": {
"@discordjs/rest": "^2.3.0",
"@envelop/sentry": "^9.0.0",
"@escape.tech/graphql-armor-max-aliases": "^2.5.0",
"@escape.tech/graphql-armor-max-depth": "^2.3.0",
"@escape.tech/graphql-armor-max-directives": "^2.2.0",
"@escape.tech/graphql-armor-max-tokens": "^2.4.0",
"@fastify/websocket": "^10.0.1",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-prometheus": "^0.52.1",
"@opentelemetry/node": "^0.24.0",
"@premid/db": "workspace:*",
"@sentry/node": "^8.17.0",
"arktype": "2.0.0-rc.6",
"defu": "^6.1.4",
"discord-api-types": "^0.37.92",
"fastify": "^4.28.1",
"graphql": "^16.9.0",
"graphql-parse-resolve-info": "^4.13.0",
"graphql-yoga": "^5.6.0",
"ioredis": "^5.3.2",
"mongoose": "^8.6.3"
},
"devDependencies": {
"@graphql-codegen/cli": "5.0.2",
"@graphql-codegen/schema-ast": "^4.1.0",
"@graphql-codegen/typescript": "4.0.9",
"@graphql-codegen/typescript-resolvers": "4.2.1",
"@parcel/watcher": "^2.4.1",
"@types/ws": "^8.5.12"
}
}

View File

@@ -1,110 +0,0 @@
import { REST } from "@discordjs/rest";
import { scope, type } from "arktype";
import { Routes } from "discord-api-types/v10";
import WebSocket from "ws";
import type { FastifyRequest } from "fastify";
import type { RawData } from "ws";
import { redis } from "../functions/createServer.js";
import { counter } from "../tracing.js";
const schema = scope({
token: {
"+": "delete",
"type": "'token'",
"token": "string.trim",
"expires": "number.epoch",
},
session: {
"+": "delete",
"type": "'session'",
"token": "string.trim",
},
validMessages: "token | session",
}).export();
export class Socket {
currentToken: typeof schema.token.infer | undefined;
currentSession: typeof schema.session.infer | undefined;
discord = new REST({ version: "10", authPrefix: "Bearer" });
constructor(
public readonly socket: WebSocket.WebSocket,
public readonly request: FastifyRequest,
) {
counter.add(1);
socket.on("message", this.onMessage.bind(this));
socket.on("close", () => this.onClose());
}
async onMessage(message: RawData) {
try {
const out = schema.validMessages(JSON.parse(message.toString()));
if (out instanceof type.errors) {
return this.close(1003, out.summary);
}
switch (out.type) {
case "token": {
this.discord.setToken(out.token);
if (!await this.isTokenValid(out)) {
return this.close(1003, "Invalid token");
}
this.currentToken = out;
break;
}
case "session": {
await redis.hdel("pmd-api.sessions", out.token);
this.currentSession = out;
break;
}
}
}
catch (error) {
console.error(error);
this.close(1011, "Internal Error");
}
}
async onClose() {
counter.add(-1);
if (!this.currentToken || !this.currentSession)
return;
await redis.hset(
"pmd-api.sessions",
this.currentSession.token,
JSON.stringify({
session: this.currentSession.token,
token: this.currentToken.token,
lastUpdated: Date.now(),
}),
);
}
async isTokenValid(token: typeof schema.token.infer) {
// ? Check the expiration date of the token
if (token.expires < Date.now())
return false;
// ? See if we can get the user's information
try {
await this.discord.get(Routes.user());
return true;
}
catch {
return false;
}
}
send(data: any) {
this.socket.send(JSON.stringify(data));
}
close(code: number = 1000, message?: string) {
if (this.socket.readyState === WebSocket.CLOSED)
return;
this.socket.close(code, message);
}
}

View File

@@ -1,10 +0,0 @@
import process from "node:process";
import { defu } from "defu";
const disabledFlags = process.env.DISABLED_FEATURE_FLAGS?.split(",") ?? [];
const flags = Object.fromEntries(disabledFlags.map(flag => [flag, false]));
export const featureFlags = defu(flags, {
WebSocketManager: true,
SessionKeepAlive: true,
});

View File

@@ -1,30 +0,0 @@
import { hostname } from "node:os";
import process from "node:process";
import { Redis } from "ioredis";
/* c8 ignore start */
export default function createRedis(): Redis {
const redis = new Redis({
connectionName: `api-${hostname()}-${process.pid.toString()}`,
lazyConnect: true,
name: "mymaster",
sentinels: process.env.REDIS_SENTINELS?.split(",").map(s => ({
host: s,
port: 26_379,
})),
});
/* c8 ignore next 3 */
redis.on("error", (error) => {
console.error("Redis error", error);
});
/* c8 ignore next 4 */
redis.on("connect", () => {
// eslint-disable-next-line no-console
console.log("Redis connected");
});
return redis;
}

View File

@@ -1,25 +0,0 @@
import { describe, expect, it } from "vitest";
describe.concurrent("createServer", () => {
it("should create a server", async () => {
const createServer = await import("./createServer.js");
const server = await createServer.default();
expect(server).toBeDefined();
expect(server).toHaveProperty("listen");
});
it("should handle graphql requests", async () => {
const createServer = await import("./createServer.js");
const server = await createServer.default();
expect(server).toBeDefined();
expect(server).toHaveProperty("listen");
const response = await server.inject({
method: "GET",
url: "/v5/graphql",
});
expect(response).toBeDefined();
expect(response.statusCode).toBe(200);
});
});

View File

@@ -1,96 +0,0 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { maxAliasesPlugin } from "@escape.tech/graphql-armor-max-aliases";
import { maxDepthPlugin } from "@escape.tech/graphql-armor-max-depth";
import { maxDirectivesPlugin } from "@escape.tech/graphql-armor-max-directives";
import { maxTokensPlugin } from "@escape.tech/graphql-armor-max-tokens";
import fastifyWebsocket from "@fastify/websocket";
import fastify from "fastify";
import { createSchema, createYoga } from "graphql-yoga";
import type { FastifyReply, FastifyRequest } from "fastify";
import { Socket } from "../classes/Socket.js";
import { resolvers } from "../graphql/resolvers/v5/index.js";
import { sessionKeepAlive } from "../routes/sessionKeepAlive.js";
import { featureFlags } from "../constants.js";
import createRedis from "./createRedis.js";
export interface FastifyContext {
request: FastifyRequest;
reply: FastifyReply;
}
const __dirname = new URL(".", import.meta.url).pathname;
export default async function createServer() {
const app = fastify({ logger: true });
const yoga = createYoga<FastifyContext>({
graphqlEndpoint: "/v5/graphql",
logging: {
/* c8 ignore next 12 */
debug: (...arguments_) => {
for (const argument of arguments_) app.log.debug(argument);
},
error: (...arguments_) => {
for (const argument of arguments_) app.log.error(argument);
},
info: (...arguments_) => {
for (const argument of arguments_) app.log.info(argument);
},
warn: (...arguments_) => {
for (const argument of arguments_) app.log.warn(argument);
},
},
plugins: [
maxAliasesPlugin(),
maxDepthPlugin(),
maxDirectivesPlugin(),
maxTokensPlugin(),
/* useSentry(), */
],
schema: createSchema<FastifyContext>({
resolvers,
typeDefs: await readFile(
resolve(__dirname, "../generated/schema-v5.graphql"),
"utf8",
),
}),
});
app.route({
handler: async (request, reply) => {
const response = await yoga.handleNodeRequest(request, {
reply,
request,
});
for (const [key, value] of response.headers.entries())
void reply.header(key, value);
void reply.status(response.status);
void reply.send(response.body);
return reply;
},
method: ["GET", "POST", "OPTIONS"],
url: "/v5/graphql",
});
app.register(fastifyWebsocket);
app.register(async (app) => {
app.get("/v5/ws", { websocket: true }, (websocket, request) => {
void new Socket(websocket, request);
});
});
app.get("/v5/feature-flags", async (request, reply) => {
void reply.send(featureFlags);
});
app.post("/v5/session-keep-alive", sessionKeepAlive);
return app;
}
export const redis = createRedis();

View File

@@ -1,33 +0,0 @@
import { type } from "arktype";
import { GraphQLError } from "graphql";
import { redis } from "../../../../functions/createServer.js";
import type { MutationResolvers } from "../../../../generated/graphql-v5.js";
const addScienceSchema = type({
identifier: "string.uuid & string.lower",
presences: "string.trim[]",
platform: {
arch: "string.trim",
os: "string.trim",
},
});
const mutation: MutationResolvers["addScience"] = async (_parent, input) => {
const out = addScienceSchema(input);
if (out instanceof type.errors)
throw new GraphQLError(out.summary);
await redis.hset(
"pmd-api.scienceUpdates",
out.identifier,
JSON.stringify(out),
);
return {
__typename: "AddScienceResult",
...out,
};
};
export default mutation;

View File

@@ -1,54 +0,0 @@
import { type } from "arktype";
import { GraphQLError } from "graphql";
import type { MutationResolvers } from "../../../../generated/graphql-v5.js";
import { redis } from "../../../../functions/createServer.js";
const heartbeatSchema = type({
"identifier": "string.uuid & string.lower",
"presence?": {
service: "string.trim",
version: "string.semver",
language: "string.trim",
since: "number.epoch",
},
"extension": {
"version": "string.semver",
"language": "string.trim",
"connected?": {
app: "number.integer",
discord: "boolean",
},
},
});
const mutation: MutationResolvers["heartbeat"] = async (_parent, input, context) => {
const out = heartbeatSchema(input);
if (out instanceof type.errors)
throw new GraphQLError(out.summary);
//* Get the user's IP address from Cloudflare headers or fallback to the request IP
const userIp = context.request.headers.get("cf-connecting-ip") || context.request.ip;
// * Use Redis Hash with 'service' in the key to store heartbeat data
const redisKey = `pmd-api.heartbeatUpdates.${out.identifier}`;
await redis.hset(redisKey, {
service: out.presence?.service,
version: out.presence?.version,
language: out.presence?.language,
since: out.presence?.since.toString(),
extension_version: out.extension.version,
extension_language: out.extension.language,
extension_connected_app: out.extension.connected?.app?.toString(),
extension_connected_discord: out.extension.connected?.discord?.toString(),
ip_address: userIp,
});
await redis.expire(redisKey, 300);
return {
__typename: "HeartbeatResult",
...out,
};
};
export default mutation;

View File

@@ -1,8 +0,0 @@
import type { MutationResolvers } from "../../../../generated/graphql-v5.js";
import addScience from "./addScience.js";
import heartbeat from "./heartbeat.js";
export const Mutation: MutationResolvers = {
addScience,
heartbeat,
};

View File

@@ -1,6 +0,0 @@
import type { QueryResolvers } from "../../../../generated/graphql-v5.js";
import presences from "./presences.js";
export const Query: QueryResolvers = {
presences,
};

View File

@@ -1,58 +0,0 @@
import { Presence } from "@premid/db";
import { parseResolveInfo } from "graphql-parse-resolve-info";
import type { PresenceSchema } from "@premid/db/Presence.js";
import type { FilterQuery } from "mongoose";
import type { QueryResolvers } from "../../../../generated/graphql-v5.js";
const resolver: QueryResolvers["presences"] = async (
_parent,
{ author, contributor, limit, query, service, start, tag },
_context,
info,
) => {
const authorFilter: FilterQuery<PresenceSchema> = author
? { "metadata.author.name": author }
: {};
const contributorFilter: FilterQuery<PresenceSchema> = contributor
? { "metadata.contributors.name": contributor }
: {};
const serviceFilter: FilterQuery<PresenceSchema> = service
? Array.isArray(service)
? { "metadata.service": { $in: service } }
: { "metadata.service": service }
: {};
const queryFilter: FilterQuery<PresenceSchema> = query
? { "metadata.service": { $options: "i", $regex: query } }
: {};
const tagFilter: FilterQuery<PresenceSchema> = tag
? { "metadata.tags": tag }
: {};
const presences = await Presence.find(
{
...authorFilter,
...contributorFilter,
...serviceFilter,
...queryFilter,
...tagFilter,
},
Object.assign(
{},
...Object.keys(parseResolveInfo(info)!.fieldsByTypeName.Presence!).map(
fieldName => ({ [fieldName]: true }),
),
) as Record<string, boolean>,
{ ...(limit ? { limit } : {}), ...(start ? { skip: start } : {}) },
);
return presences.map(presence => ({
iframeJs: presence.iframeJs,
metadata: presence.metadata,
presenceJs: presence.presenceJs,
url: presence.url,
users: 0,
}));
};
export default resolver;

View File

@@ -1,8 +0,0 @@
import type { Resolvers } from "../../../generated/graphql-v5.js";
import { Mutation } from "./Mutation/index.js";
import { Query } from "./Query/index.js";
export const resolvers: Resolvers = {
Query,
Mutation,
};

View File

@@ -1,19 +0,0 @@
type Mutation {
addScience(identifier: String!, presences: [String!]!, platform: PlatformInput!): AddScienceResult
}
input PlatformInput {
arch: String!
os: String!
}
type AddScienceResult {
identifier: String!
presences: [String!]!
platform: Platform!
}
type Platform {
arch: String!
os: String!
}

View File

@@ -1,21 +0,0 @@
type Query {
"""
Get the available languages
"""
availableLanguages: [Language!]!
}
type Language {
"""
Language code
"""
lang: String!
"""
Native name of the language, eg. 'English', 'Deutsch', 'Español', etc.
"""
nativeName: String!
"""
'ltr' or 'rtl'
"""
direction: String!
}

View File

@@ -1,22 +0,0 @@
type Query {
"""
Get the available presence languages for a specific presence
"""
availablePresenceLanguages(
"""
Presence, e.g. 'Netflix'
"""
presence: StringOrStringArray
): [PresenceLanguage!]!
}
type PresenceLanguage {
"""
Presence, e.g. 'Netflix'
"""
presence: String!
"""
The available languages for the presence
"""
languages: [Language!]!
}

View File

@@ -1,49 +0,0 @@
type Mutation {
heartbeat(
identifier: String!
presence: HeartbeatPresenceInput
extension: HeartbeatExtensionInput!
): HeartbeatResult!
}
input HeartbeatPresenceInput {
service: String!
version: String!
language: String!
since: Float!
}
input HeartbeatExtensionInput {
version: String!
language: String!
connected: HeartbeatConnectedInput
}
input HeartbeatConnectedInput {
app: Int!
discord: Boolean!
}
type HeartbeatResult {
identifier: String!
presence: HeartbeatPresence
extension: HeartbeatExtension!
}
type HeartbeatPresence {
service: String!
version: String!
language: String!
since: Float!
}
type HeartbeatExtension {
version: String!
language: String!
connected: HeartbeatConnected
}
type HeartbeatConnected {
app: Int!
discord: Boolean!
}

View File

@@ -1,62 +0,0 @@
type Query {
presences(
service: StringOrStringArray
author: String
contributor: String
start: Int
limit: Int
query: String
tag: String
): [Presence!]!
}
type Presence {
url: String!
metadata: PresenceMetadata!
presenceJs: String!
iframeJs: String
users: Int!
}
type PresenceMetadata {
author: PresenceMetadataUser!
contributors: [PresenceMetadataUser!]
altnames: [String!]
service: String!
description: Scalar! # serialize
url: Scalar! # serialize
version: String!
logo: String!
thumbnail: String!
color: String!
tags: [String!]!
category: String!
iframe: Boolean
regExp: String
iFrameRegExp: String
readLogs: Boolean
button: Boolean
warning: Boolean
settings: [PresenceMetadataSettings!]
}
type PresenceMetadataUser {
id: String!
name: String!
}
type PresenceMetadataSettings {
id: String!
title: String
icon: String
if: PresenceMetadataSettingsIf # serialize
placeholder: String
value: Scalar # serialize
values: Scalar # serialize
multiLanguage: Scalar # serialize
}
type PresenceMetadataSettingsIf {
propertyNames: String
patternProperties: Scalar
}

View File

@@ -1 +0,0 @@
scalar Scalar

View File

@@ -1 +0,0 @@
scalar StringOrStringArray

View File

@@ -1,27 +0,0 @@
/* eslint-disable no-console */
import process from "node:process";
import * as Sentry from "@sentry/node";
import { connect } from "mongoose";
import "./tracing.js";
import createServer from "./functions/createServer.js";
// TODO SETUP SENTRY
Sentry.init({
integrations: [
Sentry.graphqlIntegration(),
Sentry.mongooseIntegration(),
],
});
if (!process.env.DATABASE_URL)
throw new Error("DATABASE_URL is not set");
await connect(process.env.DATABASE_URL, { appName: "PreMiD API" });
const server = await createServer();
const url = await server.listen({
port: Number.parseInt(process.env.PORT ?? "3001"),
host: process.env.HOST ?? "0.0.0.0",
});
console.log(`Server listening at ${url}`);

View File

@@ -1,62 +0,0 @@
import process from "node:process";
import { REST } from "@discordjs/rest";
import { type } from "arktype";
import { Routes } from "discord-api-types/v10";
import type { FastifyReply, FastifyRequest } from "fastify";
import { redis } from "../functions/createServer.js";
import { featureFlags } from "../constants.js";
const schema = type({
token: "string.trim",
session: "string.trim",
version: "string.semver & string.trim",
scienceId: "string.trim",
});
export async function sessionKeepAlive(request: FastifyRequest, reply: FastifyReply) {
if (!featureFlags.SessionKeepAlive)
return reply.status(202).send();
//* Get the headers
const out = schema({
token: request.headers["x-token"],
session: request.headers["x-session"],
version: request.headers["x-version"] ?? "2.6.8",
scienceId: request.headers["x-science-id"] ?? request.headers["x-token"],
});
if (out instanceof type.errors)
return reply.status(400).send({ code: "MISSING_HEADERS", message: out.message });
if (!await isTokenValid(out.token))
return reply.status(400).send({ code: "INVALID_TOKEN", message: "The token is invalid" });
const redisKey = `pmd-api.sessions.${out.scienceId}`;
await redis.hset(redisKey, {
session: out.session,
token: out.token,
lastUpdated: Date.now(),
});
await redis.expire(redisKey, 300); // 5 minutes
const interval = Number.parseInt(process.env.SESSION_KEEP_ALIVE_INTERVAL ?? "5000"); // 5 seconds
return reply.status(200).send({
code: "OK",
message: "Session updated",
nextUpdate: interval,
});
}
async function isTokenValid(token: string) {
const discord = new REST({ version: "10", authPrefix: "Bearer" });
discord.setToken(token);
try {
await discord.get(Routes.user());
return true;
}
catch {
return false;
}
}

View File

@@ -1,18 +0,0 @@
import { ValueType } from "@opentelemetry/api";
import { PrometheusExporter } from "@opentelemetry/exporter-prometheus";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
const prometheusExporter = new PrometheusExporter();
const provider = new MeterProvider({
readers: [prometheusExporter],
});
const meter = provider.getMeter("nice");
export const counter = meter.createUpDownCounter("active_activites", {
description: "Number of active activities",
valueType: ValueType.INT,
});
prometheusExporter.startServer();

View File

@@ -1,10 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"types": ["@ark/schema"],
"outDir": "dist"
},
"include": ["src/**/*"]
}

View File

@@ -1,8 +0,0 @@
{
"extends": "./tsconfig.app.json",
"compilerOptions": {
"rootDir": ".",
"noEmit": true
},
"include": ["environment.d.ts", "src", "codegen.ts"]
}

View File

@@ -1,25 +0,0 @@
{
"name": "@premid/discord-bot",
"type": "module",
"version": "1.0.4",
"private": true,
"description": "PreMiD's discord bot",
"license": "MPL-2.0",
"main": "dist/index.js",
"files": [
"dist"
],
"scripts": {
"start": "node --enable-source-maps .",
"dev": "node --watch --env-file .env --enable-source-maps ."
},
"dependencies": {
"@premid/db": "workspace:*",
"@sentry/node": "^8.17.0",
"defu": "^6.1.4",
"discord.js": "^14.16.2",
"glob": "^11.0.0",
"mongoose": "^8.2.0",
"winston": "^3.14.2"
}
}

View File

@@ -1,61 +0,0 @@
import type { ChatInputCommandInteraction } from "discord.js";
import { SlashCommandBuilder } from "discord.js";
import { BetaUsers } from "@premid/db";
import { createStandardEmbed } from "../util/createStandardEmbed.js";
import type { Command } from "../util/loadCommands.js";
import { client, processEnv } from "../constants.js";
export default {
data: new SlashCommandBuilder()
.setName("beta")
.setDescription("Join or leave the beta program")
.addSubcommand(subcommand =>
subcommand
.setName("leave")
.setDescription("Leave the beta program"),
).addSubcommand(subcommand =>
subcommand
.setName("join")
.setDescription("Join the beta program"),
),
execute: async (interaction: ChatInputCommandInteraction) => {
if (!interaction.inGuild())
return;
if (interaction.options.getSubcommand() === "leave") {
const user = await BetaUsers.findOne({ userId: interaction.user.id });
if (!user) {
return interaction.reply({ content: "You are not a beta tester", ephemeral: true });
}
await BetaUsers.deleteOne({ userId: interaction.user.id });
const member = client.guilds.cache.get(interaction.guildId)?.members.cache.get(interaction.user.id);
await member?.roles.remove(processEnv.BETA_ROLE, "Left the beta program");
return interaction.reply({ content: "You are no longer a beta tester", ephemeral: true });
}
else if (interaction.options.getSubcommand() === "join") {
await BetaUsers.updateOne({ userId: interaction.user.id }, { $set: { userId: interaction.user.id } }, { upsert: true });
const member = client.guilds.cache.get(interaction.guildId)?.members.cache.get(interaction.user.id);
await member?.roles.add(processEnv.BETA_ROLE, "Joined the beta program");
return interaction.reply({ content: "You are now a beta tester", ephemeral: true });
}
else {
return interaction.reply({ content: "Invalid subcommand", ephemeral: true });
}
},
help: {
name: "beta",
value: "beta",
command: "/beta <join/leave>",
commandDescription: "Join or leave the beta program",
embed: createStandardEmbed({
title: "Command: /beta <join/leave>",
description: "Join or leave the beta program",
fields: [
{ name: "Usage", value: "`/beta <join/leave>`", inline: true },
{ name: "Example", value: "`/beta join`", inline: true },
],
}),
},
} satisfies Command;

View File

@@ -1,83 +0,0 @@
import {
ActionRowBuilder,
type AutocompleteInteraction,
ButtonBuilder,
ButtonStyle,
type ChatInputCommandInteraction,
SlashCommandBuilder,
} from "discord.js";
import { createStandardEmbed } from "../util/createStandardEmbed.js";
import { type Command, commands } from "../util/loadCommands.js";
export default {
data: new SlashCommandBuilder()
.setName("help")
.setDescription("Shows help and usage information for PreMiD commands")
.addStringOption(option =>
option
.setName("command")
.setDescription("The specific command to get help for")
.setAutocomplete(true),
),
autocomplete: async (interaction: AutocompleteInteraction) => {
const focusedValue = interaction.options.getFocused();
const choices = [...commands.values()]
.filter(cmd => cmd.help)
.map(cmd => ({ name: cmd.help!.name, value: cmd.help!.value }));
const filtered = choices.filter(choice => choice.name.toLowerCase().includes(focusedValue.toLowerCase()));
return interaction.respond(filtered.slice(0, 25));
},
execute: async (interaction: ChatInputCommandInteraction) => {
const command = interaction.options.getString("command");
if (command) {
const help = [...commands.values()].find(({ help }) => help?.value === command)?.help;
if (!help)
return interaction.reply({ content: "Command not found", ephemeral: true });
return interaction.reply({
embeds: [help.embed],
ephemeral: true,
});
}
const generalCommands = [];
for (const cmd of commands.values()) {
if (cmd.help) {
if (cmd.help.command)
generalCommands.push(`\`${cmd.help.command}\` - ${cmd.help.commandDescription}`);
}
}
const embed = createStandardEmbed({
title: "🛠️ PreMiD Help",
description: "PreMiD is a simple, configurable utility that allows you to show what you're doing on the web in your Discord now playing status.",
fields: [
{
name: "🔧 Commands",
value: generalCommands.join("\n") || "No commands available",
inline: false,
},
{
name: "📚 Additional Information",
value: "Use `/help <command>` for detailed information about a specific command.",
inline: false,
},
],
});
return interaction.reply({
embeds: [embed],
components: [
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setLabel("View Website").setURL("https://premid.app/").setStyle(ButtonStyle.Link),
new ButtonBuilder().setLabel("View Store").setURL("https://premid.app/store").setStyle(ButtonStyle.Link),
new ButtonBuilder().setLabel("Downloads").setURL("https://premid.app/downloads").setStyle(ButtonStyle.Link),
),
],
ephemeral: true,
});
},
} satisfies Command;

View File

@@ -1,289 +0,0 @@
import type { APIButtonComponent, AutocompleteInteraction, ChatInputCommandInteraction, ColorResolvable } from "discord.js";
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, SlashCommandBuilder } from "discord.js";
import { createStandardEmbed } from "../util/createStandardEmbed.js";
import type { Command } from "../util/loadCommands.js";
const shortInfos: {
[key: string]: {
title: string;
description: string;
emoji?: string;
image?: string;
color?: ColorResolvable;
links?: Partial<APIButtonComponent>[];
};
} = {
troubleshooting: {
title: "Troubleshooting",
emoji: "❓",
description:
"If you have problems with PreMiD, you should read our troubleshooting guide and if that doesn't help, create a new post in <#1019726199494279248>.",
links: [
{
label: "Troubleshooting Guide",
url: "https://docs.premid.app/troubleshooting",
},
],
},
modifiedClients: {
title: "Modified Clients",
color: "#FF5050",
description:
"Using a modified client is an violation of Discord's ToS and therefore you run the risk of losing your account. If you want to keep using Discord, you have to follow them and make sure you're not breaking any of the rules Discord. Even using modified clients for theming or other customizations are against Discord's ToS. If you don't believe us, read it yourself.",
links: [
{
label: "Discord's ToS",
url: "https://discordapp.com/terms",
},
{
label: "Discord's Tweet",
url: "https://twitter.com/discordapp/status/908000828690182145",
},
],
},
creatingAPresence: {
title: "Creating a Presence",
emoji: "🏗",
description:
"If you wish to add support for a service that does not have a Presence yet, you can either open an issue on GitHub to request the presence to be created or you create it yourself. If you wish to create a Presence for PreMiD you need to have basic knowledge of TypeScript. For more information and docs on how to create a Presence follow our documentation.",
links: [
{
label: "Documentation",
url: "https://docs.premid.app/dev/presence",
},
{
label: "Service Request",
url: "https://github.com/PreMiD/Presences/issues/new?assignees=&labels=Service+Request&template=service_request.yml",
},
],
},
docs: {
title: "Read the Docs",
description:
"If you have any questions regarding PreMiD, please read our documentation before creating a ticket. Presence development related queries should be redirected to <#607524579874832446>",
links: [
{
label: "Documentation",
url: "https://docs.premid.app",
},
],
},
website: {
title: "Visit Our Website",
emoji: "🌐",
description: "Press the button below to visit our website full of greatness.",
links: [
{
label: "Website",
url: "https://premid.app",
},
],
},
presenceStore: {
title: "Presence Store",
emoji: "🏪",
description: "Press the button below to visit our Presence Store full of the your favourite services!",
links: [{ label: "Presence Store", url: "https://premid.app/store" }],
},
downloadPreMiD: {
title: "Download PreMiD",
emoji: "📦",
description:
"You can download PreMiD and its extension for your browser, but don't forget that **you need both application and extension** to get PreMiD to work.\n :warning: PreMiD does not support the web version of Discord, you **must** use the desktop version of Discord.",
links: [{ label: "Downloads", url: "https://premid.app/downloads" }],
},
donate: {
title: "Donate",
emoji: "💵",
description:
"Want to support PreMiD's development? Great! You can do so by boosting our Discord server, which you will get a special role, or you can support us on Patreon!",
links: [
{ label: "Patreon", url: "https://patreon.com/Timeraa" },
{ label: "GitHub Sponsors", url: "https://github.com/sponsors/Timeraa" },
],
},
creatingATicket: {
title: "Creating a Support Ticket",
emoji: "🙋",
description:
"Recently, we have migrated to use Discord's new forum channels for our support system. You can now create a ticket by creating a new post in <#1019726199494279248>",
},
suggestingAPresence: {
title: "Suggesting a Presence",
emoji: "🗳",
description:
"If you'd like to suggest a presence, you can do this on our GitHub repository by creating a new issue with the Service Request template! If want to create a Presence yourself, you can find more information on our documentation",
links: [
{
label: "PreMiD Documentation",
url: "https://docs.premid.app/dev/presence",
},
{
label: "GitHub Repository",
url: "https://github.com/PreMiD/Presences",
},
{
label: "Service Request",
url: "https://github.com/PreMiD/Presences/issues/new?assignees=&labels=Service+Request&template=service_request.yml",
},
],
},
tos: {
title: "PreMiD and Discord",
emoji: "🧬",
description: "PreMiD is compliant to Discord's ToS and therefore you can use it without any risk of losing your Discord account.",
links: [
{
label: "Proof",
url: "https://twitter.com/discord/status/1233704070390669312",
},
],
},
unidentifiedDeveloper: {
title: "Allow apps from unidentified developers (macOS)",
description:
"Steps for **macOS Big Sur (11.0+)**:\n1. Right click on our installer.\n2. Click `Open` in the dropdown menu.\n3. Click `Open` in popup.\n\nSteps for **older macOS versions**:\n1. Open System Preferences.\n2. Go to the Security & Privacy tab.\n3. Click on the lock and enter your password or scan your fingerprint so you can make changes.\n4. Change the setting for 'Allow apps downloaded from' to 'App Store and identified developers' from just 'App Store'.",
},
reportingaPresenceBug: {
title: "Reporting a Presence bug",
emoji: "🐛",
description:
"If you've found an issue with a presence, it is important that you report your issue on the Presence repository so the bug is resolved within a timely fashion. You can report the bug using the Bug Report template, **ensuring you fill in the template properly**.",
links: [
{
label: "Presence Repository",
url: "https://github.com/PreMiD/Presences",
},
{
label: "Bug Report",
url: "https://github.com/PreMiD/Presences/issues/new?assignees=&labels=%F0%9F%90%9B+Bug&template=bug_report.yml&title=Service+Name+%7C+Service+URL",
},
],
},
adblockDetection: {
title: "Adblock Detection",
emoji: "🚫",
description:
"If our website has falsely detected the presence of an ad-blocker, you can simply press \"I don't want to support\" six times and you will be redirected to the download. Alternatively, you can find direct download links below.",
links: [
{
label: "Download Links",
url: "https://discord.com/channels/493130730549805057/527675240231206934/715852870062309386",
},
],
},
requestANewFeature: {
title: "Requesting a Presence feature",
emoji: "🗳",
description:
"Does a presence you use not support a crucial page or not support all the possible domains for the website? If you believe a presence should include more features, you should open an issue on the Presence Repository using the Feature Request template.",
links: [
{
label: "Template",
url: "https://github.com/PreMiD/Presences/issues/new?assignees=&labels=Feature+Request&template=feature_request.yml",
},
],
},
beta: {
title: "PreMiD Beta",
emoji: "✨",
description:
"Do you want cool new features? Want to use PreMiD with the browser version of Discord? Download the beta!",
links: [
{
label: "Beta Release Page",
url: "https://premid.app/beta",
},
],
},
frequentFixes: {
title: "Frequent fixes for Presence bugs",
emoji: "🗳",
description:
"There are some frequent fixes for presences, use the buttons to navigate to these.\n If this doesn't work, please submit your issue to <#1019726199494279248>",
links: [
{
label: "YouTube/Netflix",
url: "https://discord.com/channels/493130730549805057/527675240231206934/831995042469642251",
},
{
label: "YouTube",
url: "https://discord.com/channels/493130730549805057/527675240231206934/827037909504753704",
},
{
label: "General fix",
url: "https://discord.com/channels/493130730549805057/527675240231206934/723231955893747763",
},
],
},
};
export default {
data: new SlashCommandBuilder()
.setName("info")
.setDescription("Posts an information message")
.addStringOption(option =>
option
.setName("query")
.setDescription("The infomation message to search for")
.setAutocomplete(true),
)
.addUserOption(option =>
option
.setName("user")
.setDescription("User to mention")
.setRequired(false),
),
autocomplete: async (interaction: AutocompleteInteraction) => {
const focusedValue = interaction.options.getFocused();
const choices = Object.entries(shortInfos).map(([key, data]) => ({ name: data.title, value: key }));
const filtered = choices.filter(choice => choice.name.toLowerCase().includes(focusedValue.toLowerCase()));
return interaction.respond(filtered.slice(0, 25));
},
execute: async (interaction: ChatInputCommandInteraction) => {
const query = interaction.options.getString("query");
const user = interaction.options.getUser("user");
if (!query)
return interaction.reply({ content: "Please provide a query to search for", ephemeral: true });
const info = shortInfos[query];
if (!info)
return interaction.reply({ content: "No information found for that query", ephemeral: true });
const embed = createStandardEmbed({
title: `${info.emoji || "🔖"} ${info.title}`,
description: info.description,
});
let actionRow: ActionRowBuilder<ButtonBuilder> | undefined;
if (info.links) {
actionRow = new ActionRowBuilder<ButtonBuilder>();
for (const link of info.links) {
actionRow.addComponents(new ButtonBuilder({
style: ButtonStyle.Link,
...link,
}));
}
}
return interaction.reply({ embeds: [embed], content: user ? user.toString() : undefined, components: actionRow ? [actionRow] : undefined });
},
help: {
name: "info",
value: "info",
command: "/info <query> [user]",
commandDescription: "Posts an information message",
embed: createStandardEmbed({
title: "Command: /info",
description: "Posts an information message",
fields: [
{ name: "Usage", value: "`/info <query> [user]`", inline: true },
{ name: "Example", value: "`/info troubleshooting`\n`/info beta @User`", inline: true },
],
}),
},
} satisfies Command;

View File

@@ -1,98 +0,0 @@
import type { AutocompleteInteraction, ChatInputCommandInteraction } from "discord.js";
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, SlashCommandBuilder } from "discord.js";
import { Presence } from "@premid/db";
import { createStandardEmbed } from "../util/createStandardEmbed.js";
import type { Command } from "../util/loadCommands.js";
import { getPresenceList } from "../util/presenceList.js";
import { client } from "../constants.js";
export default {
data: new SlashCommandBuilder()
.setName("presence")
.setDescription("Search for a presence")
.addStringOption(option =>
option
.setName("query")
.setDescription("The presence to search for")
.setAutocomplete(true)
.setRequired(true),
),
autocomplete: async (interaction: AutocompleteInteraction) => {
const focusedValue = interaction.options.getFocused();
const presenceList = getPresenceList();
const filtered = presenceList.filter(({ service }) => service.toLowerCase().includes(focusedValue.toLowerCase()));
return interaction.respond(filtered.slice(0, 25).map(({ service }) => ({ name: service, value: service })));
},
execute: async (interaction: ChatInputCommandInteraction) => {
const query = interaction.options.getString("query");
if (!query)
return interaction.reply({ content: "Please provide a query to search for", ephemeral: true });
const presence = await Presence.findOne({ name: query }, {
_id: false,
metadata: {
service: true,
author: { id: true },
contributors: { id: true },
url: true,
description: {
en: true,
},
logo: true,
color: true,
},
});
if (!presence)
return interaction.reply({ content: "Presence not found", ephemeral: true });
const embed = createStandardEmbed({
title: presence.metadata.service,
description: presence.metadata.description.en,
color: presence.metadata.color,
fields: presence.metadata.contributors?.length
? [{
name: "Contributors",
value: presence.metadata.contributors.map(contributor => `<@${contributor.id}>`).join(", "),
}]
: undefined,
});
embed.setURL(`https://${Array.isArray(presence.metadata.url) ? presence.metadata.url[0] : presence.metadata.url}`);
embed.setThumbnail(presence.metadata.logo);
const author = await client.users.fetch(presence.metadata.author.id).catch(() => null);
if (author) {
embed.setAuthor({
name: author.username,
iconURL: author.displayAvatarURL(),
});
}
return interaction.reply({ embeds: [embed], components: [
new ActionRowBuilder<ButtonBuilder>()
.addComponents(
new ButtonBuilder()
.setLabel("Open in Store")
.setURL(`https://premid.app/store/presences/${encodeURI(presence.metadata.service)}`)
.setStyle(ButtonStyle.Link),
),
] });
},
help: {
name: "presence",
value: "presence",
command: "/presence <query>",
commandDescription: "Search for a presence",
embed: createStandardEmbed({
title: "Command: /presence",
description: "Search for a presence",
fields: [
{ name: "Usage", value: "`/presence <query>`", inline: true },
{ name: "Example", value: "`/presence YouTube`", inline: true },
],
}),
},
} satisfies Command;

View File

@@ -1,69 +0,0 @@
import process from "node:process";
import { defu } from "defu";
import { Client, GatewayIntentBits, REST } from "discord.js";
export const processEnv = defu({
TOKEN: process.env.TOKEN,
DATABASE_URL: process.env.DATABASE_URL,
SENTRY_DSN: process.env.SENTRY_DSN,
}, {
TOKEN: "",
DATABASE_URL: "mongodb://localhost:27017/premid",
SENTRY_DSN: "",
GUILD_ID: "493130730549805057",
BETA_ROLE: "591284574823120909",
ALPHA_ROLE: "694481247564595211",
});
export const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildPresences],
presence: {
status: "online",
},
});
export const rest = new REST().setToken(processEnv.TOKEN);
export const roles = {
PROJECT_LEADER: "493135149274365975",
STAFF_COORDINATOR: "691382096878370837",
ADMINISTRATOR: "685969048399249459",
PROJECT_MANAGEMENT: "673682085608816652",
REVIEWER: "630445337143935009",
DEVELOPER: "1027665813525778436",
DESIGNER: "691386502566903850",
MODERATOR: "514546359865442304",
SUPPORT_AGENT: "566417964820070421",
MARKETING_DIRECTOR: "673681900476432387",
LOCALIZATION_MANAGER: "811262682408943616",
REPRESENTATIVE: "691384256672563332",
CONTRIBUTOR: "1032759805732978708",
PATRON: "515874214750715904",
DONATOR: "502165799172309013",
BOOSTER: "585532751663333383",
PROOFREADER: "522755339448483840",
TRANSLATOR: "502148045991968788",
PRESENCE_DEV: "606222296016879722",
} as const;
export const roleColors = {
PROJECT_LEADER: "#E43725",
STAFF_COORDINATOR: "#E43725",
ADMINISTRATOR: "#E43725",
PROJECT_MANAGEMENT: "#E43725",
REVIEWER: "#3BA576",
DEVELOPER: "#3BA576",
DESIGNER: "#3BA576",
MODERATOR: "#D67118",
SUPPORT_AGENT: "#D67118",
MARKETING_DIRECTOR: "#3BA576",
LOCALIZATION_MANAGER: "#3BA576",
REPRESENTATIVE: "#3BA576",
CONTRIBUTOR: "#EB459E",
PATRON: "#E5472F",
DONATOR: "#FFAA33",
BOOSTER: "#F265FF",
PROOFREADER: "#00B0E6",
TRANSLATOR: "#2286D0",
PRESENCE_DEV: "#96A5E9",
} as const;

View File

@@ -1,31 +0,0 @@
import { Events } from "discord.js";
import { DiscordUsers, Presence } from "@premid/db";
import { client, roles as rolesEnv } from "../constants.js";
client.on(Events.GuildMemberAdd, async (member) => {
const [presence] = await Promise.all([
Presence.findOne({
$or: [{ "metadata.author.id": member.id }, { "metadata.contributors.id": member.id }],
}, { name: true }),
DiscordUsers.updateOne(
{ userId: member.id },
{
$set: {
avatar: member.user.avatar,
created: member.user.createdTimestamp,
discriminator: member.user.discriminator,
userId: member.id,
username: member.user.username,
},
},
{ upsert: true },
),
]);
//* User should have Presence Developer Role
if (presence) {
if (!member.roles.cache.has(rolesEnv.PRESENCE_DEV)) {
await member.roles.add(rolesEnv.PRESENCE_DEV, "User should have Presence Developer Role");
}
}
});

View File

@@ -1,12 +0,0 @@
import { Events } from "discord.js";
import { AlphaUsers, BetaUsers, Credits, DiscordUsers } from "@premid/db";
import { client } from "../constants.js";
client.on(Events.GuildMemberRemove, async (member) => {
await Promise.all([
BetaUsers.deleteOne({ userId: member.id }),
AlphaUsers.deleteOne({ userId: member.id }),
DiscordUsers.deleteOne({ userId: member.id }),
Credits.deleteOne({ userId: member.id }),
]);
});

View File

@@ -1,86 +0,0 @@
import { Events } from "discord.js";
import { AlphaUsers, BetaUsers, Credits, DiscordUsers } from "@premid/db";
import { client, processEnv, roleColors, roles as rolesEnv } from "../constants.js";
client.on(Events.GuildMemberUpdate, async (oldMember, newMember) => {
const highestRole = newMember.roles.cache
.filter(role => (Object.values(rolesEnv) as string[]).includes(role.id))
.sort((a, b) => b.position - a.position)
.at(0);
await Promise.all([
DiscordUsers.updateOne(
{ userId: newMember.id },
{
$set: {
avatar: newMember.user.avatar,
created: newMember.user.createdTimestamp,
discriminator: newMember.user.discriminator,
userId: newMember.id,
username: newMember.user.username,
},
},
{ upsert: true },
),
highestRole
? Credits.updateOne(
{ userId: newMember.id },
{
$set: {
userId: newMember.id,
name: newMember.user.username,
tag: newMember.user.discriminator,
avatar: newMember.user.displayAvatarURL({
extension: "png",
forceStatic: false,
}),
premium_since: newMember.premiumSince !== null ? newMember.premiumSinceTimestamp! : undefined,
role: highestRole.name,
roleId: highestRole.id,
roles: newMember.roles.cache.filter(r => r.name !== "@everyone").map(r => r.name),
roleIds: newMember.roles.cache.filter(r => r.name !== "@everyone").map(r => r.id),
roleColor: roleColors[
Object.entries(rolesEnv).find(([, id]) => id === highestRole.id)![0] as keyof typeof roleColors
],
rolePosition: highestRole.position,
status: newMember.presence?.status ?? "offline",
flags: newMember.user.flags?.toArray() ?? [],
},
},
{ upsert: true },
)
: Promise.resolve(),
]);
const roles = newMember.roles.cache.map(role => role.id);
//* User should have Alpha Role
if (roles.includes(rolesEnv.BOOSTER) || roles.includes(rolesEnv.PATRON) || newMember.roles.cache.has(processEnv.ALPHA_ROLE)) {
if (!newMember.roles.cache.has(processEnv.ALPHA_ROLE)) {
await newMember.roles.add(processEnv.ALPHA_ROLE, "User should have Alpha Role");
}
if (newMember.roles.cache.has(processEnv.BETA_ROLE)) {
await newMember.roles.remove(processEnv.BETA_ROLE, "User should have Alpha Role");
}
await Promise.all([
AlphaUsers.updateOne({ userId: newMember.id }, { $set: { userId: newMember.id } }, { upsert: true }),
BetaUsers.deleteOne({ userId: newMember.id }),
]);
return;
}
//* User should have Beta Role
const betaUser = await BetaUsers.findOne({ userId: newMember.id });
if (roles.includes(rolesEnv.DONATOR) || betaUser || newMember.roles.cache.has(processEnv.BETA_ROLE) || oldMember.roles.cache.has(processEnv.ALPHA_ROLE)) {
if (newMember.roles.cache.has(processEnv.ALPHA_ROLE)) {
await newMember.roles.remove(processEnv.ALPHA_ROLE, "User should have Beta Role");
}
if (!newMember.roles.cache.has(processEnv.BETA_ROLE)) {
await newMember.roles.add(processEnv.BETA_ROLE, "User should have Beta Role");
}
await Promise.all([
BetaUsers.updateOne({ userId: newMember.id }, { $set: { userId: newMember.id } }, { upsert: true }),
AlphaUsers.deleteOne({ userId: newMember.id }),
]);
}
});

View File

@@ -1,42 +0,0 @@
import { Events, InteractionType } from "discord.js";
import { client } from "../constants.js";
import { commands } from "../util/loadCommands.js";
import { logger } from "../util/logger.js";
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.inGuild()) {
logger.debug("Interaction received outside of a guild, ignoring.");
return;
}
if (interaction.type === InteractionType.ApplicationCommand || interaction.type === InteractionType.ApplicationCommandAutocomplete) {
const { commandName } = interaction;
logger.info(`Command "${commandName}" ${interaction.type === InteractionType.ApplicationCommandAutocomplete ? "autocomplete" : "executed"} in guild ${interaction.guildId} by user ${interaction.user.id}`);
if (!commands.has(commandName.toLowerCase()))
return;
const command = commands.get(commandName.toLowerCase())!;
if (interaction.type === InteractionType.ApplicationCommandAutocomplete) {
if (command.autocomplete) {
try {
await command.autocomplete(interaction);
logger.debug(`Autocomplete for command "${commandName}" handled successfully`);
}
catch (error) {
logger.error(`Error handling autocomplete for command "${commandName}":`, error);
}
}
return;
}
try {
await command.execute(interaction);
logger.debug(`Command "${commandName}" executed successfully`);
}
catch (error) {
logger.error(`Error executing command "${commandName}":`, error);
}
}
});

View File

@@ -1,180 +0,0 @@
import type { GuildMember } from "discord.js";
import { Events } from "discord.js";
import { AlphaUsers, BetaUsers, Credits, Presence } from "@premid/db";
import { client, processEnv, roleColors, roles as rolesEnv } from "../constants.js";
import { logger } from "../util/logger.js";
client.once(Events.ClientReady, async () => {
logger.debug("Giving roles to members");
const guild = await client.guilds.fetch(processEnv.GUILD_ID);
const members = await guild.members.fetch();
let count = 0;
for (const [, member] of members) {
count++;
const roles = member.roles.cache.map(role => role.id);
//* User should have Alpha Role
if (roles.includes(rolesEnv.BOOSTER) || roles.includes(rolesEnv.PATRON) || member.roles.cache.has(processEnv.ALPHA_ROLE)) {
if (!member.roles.cache.has(processEnv.ALPHA_ROLE)) {
await member.roles.add(processEnv.ALPHA_ROLE, "User should have Alpha Role");
}
if (member.roles.cache.has(processEnv.BETA_ROLE)) {
await member.roles.remove(processEnv.BETA_ROLE, "User should have Alpha Role");
}
await Promise.all([
AlphaUsers.updateOne({ userId: member.id }, { $set: { userId: member.id } }, { upsert: true }),
BetaUsers.deleteOne({ userId: member.id }),
]);
}
else {
const betaUser = await BetaUsers.findOne({ userId: member.id });
//* User should have Beta Role
if (roles.includes(rolesEnv.DONATOR) || betaUser || member.roles.cache.has(processEnv.BETA_ROLE)) {
if (member.roles.cache.has(processEnv.ALPHA_ROLE)) {
await member.roles.remove(processEnv.ALPHA_ROLE, "User should have Beta Role");
}
if (!member.roles.cache.has(processEnv.BETA_ROLE)) {
await member.roles.add(processEnv.BETA_ROLE, "User should have Beta Role");
}
await Promise.all([
BetaUsers.updateOne({ userId: member.id }, { $set: { userId: member.id } }, { upsert: true }),
AlphaUsers.deleteOne({ userId: member.id }),
]);
}
}
if (count % 1000 === 0) {
logger.debug(`Processed ${count}/${members.size} members`);
}
}
logger.debug(`Gave roles to ${count}/${members.size} members`);
//* Presence Developers
logger.debug("Checking for presence developers");
const presenceDevelopers = await Presence.find({}, {
"metadata.author.id": true,
"metadata.contributors.id": true,
"_id": false,
});
for (const presenceDeveloper of [
...new Set(
presenceDevelopers
.map(presence => [presence.metadata.author.id, ...(presence.metadata.contributors?.map(c => c.id) || [])])
.flat(),
),
]) {
const member = guild.members.cache.get(presenceDeveloper);
if (!member)
continue;
if (!member.roles.cache.has(rolesEnv.PRESENCE_DEV)) {
await member.roles.add(rolesEnv.PRESENCE_DEV, "User should have Presence Developer Role");
}
}
logger.debug("Checked for presence developers");
//* Update Credits
const usersToCredit = new Set<GuildMember>();
for (const roleId of Object.values(rolesEnv)) {
const role = await guild.roles.fetch(roleId);
if (!role)
continue;
for (const member of role.members.values()) {
usersToCredit.add(member);
}
}
const usersToRemove = await Credits.find({ userId: { $nin: [...usersToCredit].map(member => member.user.id) } });
await Credits.bulkWrite([
...usersToRemove.map(user => ({ deleteOne: { filter: { userId: user.userId } } })),
...[...usersToCredit].map((member) => {
const highestRole = member.roles.cache
.filter(role => (Object.values(rolesEnv) as string[]).includes(role.id))
.sort((a, b) => b.position - a.position)
.at(0)!;
const color = roleColors[
Object.entries(rolesEnv).find(([, id]) => id === highestRole.id)![0] as keyof typeof roleColors
];
return {
updateOne: {
filter: { userId: member.id },
update: {
$set: {
userId: member.id,
name: member.user.username,
tag: member.user.discriminator,
avatar: member.user.displayAvatarURL({
extension: "png",
forceStatic: false,
}),
premium_since: member.premiumSince !== null ? member.premiumSinceTimestamp! : undefined,
role: highestRole.name,
roleId: highestRole.id,
roles: member.roles.cache.filter(r => r.name !== "@everyone").map(r => r.name),
roleIds: member.roles.cache.filter(r => r.name !== "@everyone").map(r => r.id),
roleColor: color,
rolePosition: highestRole.position,
status: member.presence?.status ?? "offline",
flags: member.user.flags?.toArray() ?? [],
},
},
upsert: true,
},
};
}),
]);
logger.debug("Updated Credits");
//* Beta can be requested from the website so we need to periodically check for that, presence developers are external too so we need to periodically check for that
setInterval(async () => {
//* Beta Users
logger.debug("Checking for beta users");
const betaUsers = await BetaUsers.find({});
const guild = await client.guilds.fetch(processEnv.GUILD_ID);
for (const betaUser of betaUsers) {
const member = guild.members.cache.get(betaUser.userId);
if (!member)
continue;
if (!member.roles.cache.has(processEnv.BETA_ROLE)) {
await member.roles.add(processEnv.BETA_ROLE, "User should have Beta Role");
}
}
logger.debug("Checked for beta users");
//* Presence Developers
logger.debug("Checking for presence developers");
const presenceDevelopers = await Presence.find({}, {
"metadata.author.id": true,
"metadata.contributors.id": true,
"_id": false,
});
for (const presenceDeveloper of [
...new Set(
presenceDevelopers
.map(presence => [presence.metadata.author.id, ...(presence.metadata.contributors?.map(c => c.id) || [])])
.flat(),
),
]) {
const member = guild.members.cache.get(presenceDeveloper);
if (!member)
continue;
if (!member.roles.cache.has(rolesEnv.PRESENCE_DEV)) {
await member.roles.add(rolesEnv.PRESENCE_DEV, "User should have Presence Developer Role");
}
}
logger.debug("Checked for presence developers");
}, 1000 * 60 * 5);
});

View File

@@ -1,89 +0,0 @@
import process from "node:process";
import { connect } from "mongoose";
import { Routes } from "discord.js";
import * as Sentry from "@sentry/node";
import { client, processEnv, rest } from "./constants.js";
import { getActivity } from "./util/getActivity.js";
import loadCommands, { commands } from "./util/loadCommands.js";
import loadEvents from "./util/loadEvents.js";
import { logger } from "./util/logger.js";
import { updatePresenceList } from "./util/presenceList.js";
Sentry.init({
integrations: [
Sentry.mongooseIntegration(),
],
dsn: processEnv.SENTRY_DSN,
});
logger.info("Starting bot initialization...");
try {
await loadCommands();
logger.info("Commands loaded successfully");
}
catch (error) {
logger.error("Error loading commands:", error);
process.exit(1);
}
try {
await loadEvents();
logger.info("Events loaded successfully");
}
catch (error) {
logger.error("Error loading events:", error);
process.exit(1);
}
try {
await connect(processEnv.DATABASE_URL, { appName: "PreMiD Discord Bot" });
logger.info("Successfully connected to database");
await updatePresenceList();
logger.info("Successfully updated presence list");
}
catch (error) {
logger.error("Error connecting to database:", error);
process.exit(1);
}
try {
await client.login(processEnv.TOKEN);
logger.info("Bot logged in successfully");
client.user?.setActivity(getActivity({}));
}
catch (error) {
logger.error("Failed to log in:", error);
process.exit(1);
}
client.once("ready", async (client) => {
logger.info(`Bot is ready! Logged in as ${client.user?.tag}`);
try {
//* Register guild-specific commands
await rest.put(Routes.applicationGuildCommands(client.application.id, processEnv.GUILD_ID), {
body: Array.from(commands.values()).map(({ data }) => data),
});
//* Clear global commands
await rest.put(Routes.applicationCommands(client.application.id), { body: [] });
logger.info("Successfully registered commands");
}
catch (error) {
logger.error("Failed to register commands:", error);
process.exit(1);
}
});
setInterval(async () => {
const newActivity = getActivity({
previous: client.user?.presence.activities[0]?.name,
});
client.user?.setActivity(newActivity);
logger.debug(`Updated bot activity to: ${newActivity.name}`);
}, 60000);
setInterval(() => {
updatePresenceList();
}, 1000 * 60 * 5);

View File

@@ -1,25 +0,0 @@
import { type APIEmbedField, type ColorResolvable, EmbedBuilder } from "discord.js";
interface StandardEmbedOptions {
title: string;
description: string;
fields?: APIEmbedField[];
footer?: string;
color?: number | string;
}
export function createStandardEmbed({
title,
description,
fields = [],
footer = "PreMiD",
color = "Blurple",
}: StandardEmbedOptions): EmbedBuilder {
return new EmbedBuilder()
.setColor(color as ColorResolvable)
.setTitle(title)
.setDescription(description)
.addFields(fields)
.setFooter({ text: footer })
.setTimestamp();
}

View File

@@ -1,22 +0,0 @@
import type { ActivitiesOptions } from "discord.js";
import { ActivityType } from "discord.js";
import { getPresenceList } from "./presenceList.js";
export function getActivity({ previous }: {
previous?: string;
}): ActivitiesOptions {
const presenceList = getPresenceList();
const statuses = presenceList.filter(status => status.service !== previous);
const selectedStatus = statuses[Math.floor(Math.random() * statuses.length)]!;
return {
type: selectedStatus.category === "music"
? ActivityType.Listening
: selectedStatus.category === "anime"
? ActivityType.Watching
: selectedStatus.category === "videos"
? ActivityType.Watching
: ActivityType.Playing,
name: selectedStatus.service,
};
}

View File

@@ -1,39 +0,0 @@
import { resolve } from "node:path";
import type { APIApplicationCommandOptionChoice, EmbedBuilder, SharedSlashCommandOptions, SlashCommandSubcommandsOnlyBuilder } from "discord.js";
import { glob } from "glob";
export interface CommandHelp extends APIApplicationCommandOptionChoice<string> {
embed: EmbedBuilder;
command?: string;
commandDescription?: string;
}
export interface Command {
data: SharedSlashCommandOptions<any> | SlashCommandSubcommandsOnlyBuilder;
execute: (interaction: any) => Promise<any>;
autocomplete?: (interaction: any) => Promise<any>;
help?: CommandHelp;
}
export const commands = new Map<string, Command>();
export default async function loadCommands() {
for (const file of await glob("*.js", { cwd: resolve(import.meta.dirname, "../commands") })) {
const imported = await import(`../commands/${file}`);
const name = typeof imported.default.data === "function" ? imported.default.data().name : imported.default.data.name;
commands.set(name.toLowerCase(), imported.default);
if (imported.default.init) {
try {
await imported.default.init();
}
catch (error) {
//* Failed to initialize command
console.error(`Failed to initialize command ${name}:`, error);
}
}
}
return commands;
}

View File

@@ -1,8 +0,0 @@
import { resolve } from "node:path";
import { glob } from "glob";
export default async function loadEvents() {
for (const file of await glob("*.js", { cwd: resolve(import.meta.dirname, "../events") })) {
import(`../events/${file}`);
}
}

View File

@@ -1,41 +0,0 @@
import process from "node:process";
import { createLogger, format, transports } from "winston";
export const logger = createLogger({
level: process.env.NODE_ENV === "production" ? (process.env.LOG_LEVEL || "info") : (process.env.LOG_LEVEL || "debug"),
format: format.combine(
format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
format.errors({ stack: true }),
format.splat(),
format.json(),
),
transports: [
new transports.Console({
format: format.combine(
format.colorize(),
format.printf(({ timestamp, level, message, ...metadata }) => {
let msg = `${timestamp} [${level}] : ${message}`;
if (Object.keys(metadata).length > 0) {
msg += `\n${JSON.stringify(metadata, null, 2)}`;
}
return msg;
}),
),
}),
new transports.File({
filename: "error.log",
level: "error",
format: format.combine(
format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
format.json(),
),
}),
new transports.File({
filename: "combined.log",
format: format.combine(
format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
format.json(),
),
}),
],
});

View File

@@ -1,17 +0,0 @@
import { Presence } from "@premid/db";
import type { PresenceMetadataCategory } from "@premid/db/Presence.js";
import { logger } from "./logger.js";
let presenceList: { service: string; category: PresenceMetadataCategory }[] = [];
export async function updatePresenceList() {
presenceList = (await Presence.find({}, { metadata: { category: true, service: true } })).map(presence => ({
service: presence.metadata.service,
category: presence.metadata.category,
}));
logger.debug(`Updated presence list with ${presenceList.length} presences`);
}
export function getPresenceList() {
return presenceList;
}

View File

@@ -1,8 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist"
}
}

View File

@@ -1,8 +0,0 @@
{
"extends": "./tsconfig.app.json",
"compilerOptions": {
"types": ["@types/node"],
"noEmit": true
},
"include": ["src"]
}

View File

@@ -1 +0,0 @@
cache

View File

@@ -1,129 +0,0 @@
import { defineConfig } from "vitepress";
// https://vitepress.dev/reference/site-config
export default defineConfig({
title: "Documentation",
description: "Official Documentation",
locales: {
root: {
label: "English",
lang: "en-US",
},
de: {
label: "Deutsch",
lang: "de-DE",
},
},
themeConfig: {
nav: [
{
text: "Presence Development",
link: "/dev/getting-started",
},
{
text: "Reference",
link: "/reference/presence",
},
],
sidebar: {
"/default": {
base: "/",
items: [
{
text: "Getting Started",
link: "/",
items: [
{
text: "Introduction",
link: "/introduction/",
},
{
text: "Installation",
link: "/installation/",
},
{
text: "Setup",
link: "/setup/",
},
{
text: "FAQ",
link: "/faq/",
},
],
},
{
text: "Development",
link: "/development/",
items: [
{
text: "Presence Development",
link: "/presence-development/",
collapsed: true,
items: [
{
text: "Getting Started",
link: "/presence-development/getting-started/",
},
{
text: "Creating a Presence",
link: "/presence-development/creating-a-presence/",
},
],
},
],
},
{
text: "Contribute",
link: "/contribute/",
items: [
{
text: "Report a Bug",
link: "https://github.com/PreMiD",
},
{
text: "Submit a Feature",
link: "https://discord.premid.app",
},
{
text: "Donate",
link: "https://patreon.com/Timeraa",
},
{
text: "Translate",
link: "https://crowdin.com/project/premid",
},
],
},
],
},
"/reference/": {
base: "/reference",
items: [
{
text: "Reference",
link: "/presence",
items: [
{
text: "Presence",
link: "/presence",
},
{
text: "Iframe",
link: "/iframe",
},
],
},
],
},
},
socialLinks: [
{ icon: "github", link: "https://github.com/PreMiD" },
{ icon: "discord", link: "https://discord.premid.app" },
{ icon: "x", link: "https://x.com/PreMiDapp" },
],
i18nRouting: true,
logo: "/logo.svg",
search: { provider: "local" },
},
lastUpdated: true,
});

View File

@@ -1,77 +0,0 @@
# Creating a Presence
## Introduction
PreMiD Presences are the core of the PreMiD application. They allow you to add support for your favorite websites and services, or create your own custom Presences. This guide will walk you through the process of creating a Presence for PreMiD.
Please go through the [Getting Started](./getting-started) guide before proceeding with this guide. It will help you set up your development environment and install the necessary tools.
To make the process of creating a Presence easier, we have provided a command-line interface (CLI) tool. This tool will help you generate a new Presence project with all the necessary files and configurations, so you can start coding your Presence right away.
The CLI will also help you build and test your Presence before submitting it to the PreMiD Store. Testing is required to ensure your Presence works as expected and meets the quality standards. Proof that your Presence works is required for it to be approved. This is usually done by providing a video or a screenshot of your Presence in action.
## Creating a New Presence
To create a new Presence, we will run some scripts. This will generate a new Presence project with all the necessary files and configurations. To create a new Presence, follow these steps:
1. Open your terminal and run the following command:
```sh
pnpm create
```
2. Follow the on-screen instructions to create a new Presence project.
### Coding your Presence
Once you have created a new Presence project, you can start coding your Presence. First of all, you need to understand the structure of a Presence project.
#### Presence Structure
A Presence project consists of the following files and directories:
- `metadata.json`: This file contains the metadata for your Presence, such as the name, description, and version.
- `presence.ts`: This file contains the code for your Presence. This is where you will write the logic to detect the presence of your website or service.
- `iframe.ts` (optional): This file contains the code for the Presence's iframe. This is where we will be able to interact with embedded iframes on the website.
#### Development Server
Let's start a Development Server to build and test your Presence. Follow these steps:
1. Start a development server to be able to build and test your Presence:
```sh
pnpm dev "Your Presence Name"
```
2. Open your browser and go to the PreMiD Extension settings page, then enable Developer Mode.
3. You should now see your new Presence in the list of Presences.
#### Editing the `presence.ts` File
In order to fetch the data from the website, you need to write the logic in the `presence.ts` file. We will use native JavaScript functions to fetch the data from the website. Let's see an example:
```ts
const presence = new Presence({
clientId: "Your Client ID",
});
const enum Asset {
Logo = "https://cdn.rcd.gg/PreMiD.png",
}
presence.on("UpdateData", async () => {
const title = document.querySelector("title");
const description = document.querySelector("meta[name=\"description\"]");
const data: PresenceData = {
details: title.textContent,
state: description.getAttribute("content"),
largeImageKey: Asset.Logo,
};
presence.setActivity(data);
});
```
In this example, we are fetching the title and description of the website and setting them as the Presence details and state, respectively. We are also setting a custom logo as the large image key.

View File

@@ -1,50 +0,0 @@
# Getting Started
## Installation
### Prerequisites
- [Git](https://git-scm.com/).
- [Node.js](https://nodejs.org/) version 18 or higher, includes [Corepack](https://github.com/nodejs/corepack) by default.
- Terminal for accessing PreMiD's Developer Tools via its command-line interface (CLI).
- Text Editor with [TypeScript](https://www.typescriptlang.org/) syntax highlighting support.
- [Visual Studio Code](https://code.visualstudio.com/) is recommended, as it includes TypeScript support out-of-the-box.
### Clone the Repository
- Open your terminal and run the following command:
```sh
git clone https://github.com/PreMiD/Presences.git
```
- Change your working directory to the repository:
```sh
cd Presences
```
### Install Dependencies
- Ensure you have Node.js installed by running:
```sh
node -v
```
If you see a version number, Node.js is installed. Make sure you have Node.js version 18 or higher.
- Enable Corepack by running:
```sh
corepack enable
```
- Install the project dependencies:
```sh
pnpm install
```
## Coding your Presence
Follow the [Creating a Presence](./creating-a-presence) guide to get started with coding your own Presence.
## Submitting your Presence
Once you've finished coding your Presence, you can submit it to the PreMiD Store for others to use. Follow the [Submitting a Presence](./submitting-a-presence) guide to learn how to submit your Presence.
A member of the PreMiD Team will review your submission and if it meets the guidelines, it will be added to the PreMiD Store for everyone to use. If your submission is rejected, you will receive feedback on how to improve it.
Please note that all submissions are subject to review and approval by the PreMiD Team. We reserve the right to reject any submission that does not meet our guidelines or quality standards. Reviews may take up to 7 days to complete.

View File

@@ -1,5 +0,0 @@
# Getting Started
Welcome to the official documentation for PreMiD! This guide will help you get started with PreMiD.
## Installation

View File

@@ -1,51 +0,0 @@
---
# https://vitepress.dev/reference/default-theme-home-page
layout: home
hero:
name: "PreMiD"
text: "Documentation"
tagline: "The official documentation for PreMiD."
image:
src: /logo.svg
alt: PreMiD Logo
actions:
- theme: brand
text: Get Started
link: /getting-started
- theme: alt
text: Presence Development
link: /dev/getting-started
features:
- icon: 🛠️
title: Extensible
details: Add Presences for your favorite websites and services. Or create your own!
- icon: 🌐
title: Cross-Platform
details: PreMiD is available for all major browsers and platforms.
- icon: 🚀
title: Lightweight
details: PreMiD is designed to be as lightweight as possible, so it won't slow down your system.
---
<style>
:root {
--vp-home-hero-name-color: transparent;
--vp-home-hero-name-background: -webkit-linear-gradient(120deg, rgb(209, 122, 254) 30%, rgb(89, 195, 246));
--vp-home-hero-image-background-image: linear-gradient(-45deg, rgb(209, 122, 254) 50%, rgb(89, 195, 246) 50%);
--vp-home-hero-image-filter: blur(44px);
}
@media (min-width: 640px) {
:root {
--vp-home-hero-image-filter: blur(56px);
}
}
@media (min-width: 960px) {
:root {
--vp-home-hero-image-filter: blur(68px);
}
}
</style>

View File

@@ -1,14 +0,0 @@
{
"name": "@premid/docs",
"version": "0.0.0",
"private": true,
"description": "Documentation for Premid",
"scripts": {
"dev": "vitepress dev",
"build": "vitepress build",
"preview": "vitepress preview"
},
"devDependencies": {
"vitepress": "1.3.1"
}
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 288 KiB

View File

@@ -1,68 +0,0 @@
# Presence Class
The `Presence` class is the main class used to create a Presence.
## Overview
The `Presence` class is the main class used to create a Presence. It is used to interact with the PreMiD Extension.
### Example
```javascript
const presence = new Presence({
clientId: "<Your Client ID>",
});
presence.on("UpdateData", () => {
// Logic to update the presence data
presence.setActivity({
details: "Example Presence",
state: "Example State",
});
});
```
## Constructor
### `new Presence(options: PresenceOptions)`
Creates a new `Presence` instance.
#### Parameters
- `options` (`PresenceOptions`): The options for the Presence.
#### Returns
- `Presence`: The new `Presence` instance.
## Properties
### `clientId: string`
The Client ID of the Presence.
## Methods
### `setActivity(activity: PresenceActivity)`
Sets the activity of the Presence.
#### Parameters
- `activity` (`PresenceActivity`): The activity to set.
### `clearActivity()`
Clears the activity of the Presence.
### `on(event: string, listener: Function)`
Adds a listener to an event.
#### Parameters
- `event` (`string`): The event to listen to.
- `listener` (`Function`): The listener to add.

View File

@@ -1,7 +1,7 @@
{
"name": "@premid/pd",
"type": "module",
"version": "1.1.9",
"version": "1.2.4",
"private": true,
"description": "A small service to shorten image urls to get around Discord's 256 character limit",
"license": "MPL-2.0",

View File

@@ -1,3 +1,5 @@
import createKeyv from "./functions/createKeyv.js";
export default createKeyv();
export const keyv = createKeyv();
export const ttl = 30 * 60 * 1000;

View File

@@ -5,7 +5,7 @@ import mime from "mime-types";
import { nanoid } from "nanoid";
import type { RouteHandlerMethod } from "fastify";
import keyv from "../keyv.js";
import { keyv, ttl } from "../keyv.js";
const handler: RouteHandlerMethod = async (request, reply) => {
const { body } = request;
@@ -33,16 +33,14 @@ const handler: RouteHandlerMethod = async (request, reply) => {
const existingUrl = await keyv.get(hash);
if (existingUrl) {
void reply.header("Cache-control", `public, max-age=${(30 * 60).toString()}`);
return reply.send(process.env.BASE_URL! + existingUrl);
}
const uniqueId = `${nanoid(10)}.${type}`;
await keyv.set(hash, uniqueId, 30 * 60 * 1000);
await keyv.set(uniqueId, body, 30 * 60 * 1000);
await keyv.set(hash, uniqueId, ttl);
await keyv.set(uniqueId, body, ttl);
void reply.header("Cache-control", `public, max-age=${(30 * 60).toString()}`);
return reply.send(process.env.BASE_URL! + uniqueId);
};

View File

@@ -5,7 +5,7 @@ import { fileTypeFromBuffer } from "file-type";
import { nanoid } from "nanoid";
import type { RouteHandlerMethod } from "fastify";
import keyv from "../keyv.js";
import { keyv, ttl } from "../keyv.js";
const handler: RouteHandlerMethod = async (request, reply) => {
if (!request.isMultipart())
@@ -37,18 +37,16 @@ const handler: RouteHandlerMethod = async (request, reply) => {
const existingUrl = await keyv.get(hash);
if (existingUrl) {
void reply.header("Cache-control", `public, max-age=${(30 * 60).toString()}`);
return reply.send(process.env.BASE_URL! + existingUrl);
}
const uniqueId = `${nanoid(10)}.${type.ext}`;
await Promise.all([
keyv.set(hash, uniqueId, 30 * 60 * 1000),
keyv.set(uniqueId, body, 30 * 60 * 1000),
keyv.set(hash, uniqueId, ttl),
keyv.set(uniqueId, body, ttl),
]);
void reply.header("Cache-control", `public, max-age=${(30 * 60).toString()}`);
return reply.send(process.env.BASE_URL! + uniqueId);
};

View File

@@ -61,4 +61,92 @@ describe.concurrent("/create", async () => {
expect(result2.statusCode).toBe(200);
expect(result2.body).toStrictEqual(body);
});
it("should preserve file extension when URL has a valid image extension", async () => {
const result = await server.inject({
method: "GET",
url: `/create/https://www.exampl${"e".repeat(256)}.com/image.png`,
});
expect(result.statusCode).toBe(200);
expect(result.body).toStrictEqual(expect.any(String));
expect(result.body).toMatch(/\.png$/);
});
it("should preserve file extension when URL has .jpg extension", async () => {
const result = await server.inject({
method: "GET",
url: `/create/https://www.exampl${"e".repeat(256)}.com/photo.jpg`,
});
expect(result.statusCode).toBe(200);
expect(result.body).toStrictEqual(expect.any(String));
expect(result.body).toMatch(/\.jpg$/);
});
it("should preserve file extension when URL has .webp extension", async () => {
const result = await server.inject({
method: "GET",
url: `/create/https://www.exampl${"e".repeat(256)}.com/image.webp`,
});
expect(result.statusCode).toBe(200);
expect(result.body).toStrictEqual(expect.any(String));
expect(result.body).toMatch(/\.webp$/);
});
it("should preserve file extension when URL has .png with query parameters", async () => {
const result = await server.inject({
method: "GET",
url: `/create/https://www.exampl${"e".repeat(256)}.com/image.png?ref=example`,
});
expect(result.statusCode).toBe(200);
expect(result.body).toStrictEqual(expect.any(String));
expect(result.body).toMatch(/\.png$/);
});
it("should preserve file extension when URL has .gif with complex query parameters", async () => {
const result = await server.inject({
method: "GET",
url: `/create/https://www.exampl${"e".repeat(256)}.com/animated.gif?size=large&quality=high`,
});
expect(result.statusCode).toBe(200);
expect(result.body).toStrictEqual(expect.any(String));
expect(result.body).toMatch(/\.gif$/);
});
it("should not preserve file extension when URL has invalid extension", async () => {
const result = await server.inject({
method: "GET",
url: `/create/https://www.exampl${"e".repeat(256)}.com/document.pdf`,
});
expect(result.statusCode).toBe(200);
expect(result.body).toStrictEqual(expect.any(String));
expect(result.body).not.toMatch(/\.pdf$/);
});
it("should work normally when URL has no file extension", async () => {
const result = await server.inject({
method: "GET",
url: `/create/https://www.exampl${"e".repeat(256)}.com/page`,
});
expect(result.statusCode).toBe(200);
expect(result.body).toStrictEqual(expect.any(String));
expect(result.body).not.toMatch(/\.\w+$/);
});
it("should handle case-insensitive extensions", async () => {
const result = await server.inject({
method: "GET",
url: `/create/https://www.exampl${"e".repeat(256)}.com/image.PNG`,
});
expect(result.statusCode).toBe(200);
expect(result.body).toStrictEqual(expect.any(String));
expect(result.body).toMatch(/\.png$/); //* Should be lowercase in result
});
});

View File

@@ -4,7 +4,7 @@ import process from "node:process";
import { nanoid } from "nanoid";
import type { RouteHandlerMethod } from "fastify";
import keyv from "../keyv.js";
import { keyv, ttl } from "../keyv.js";
const handler: RouteHandlerMethod = async (request, reply) => {
const url = request.url.replace("/create/", "").trim();
@@ -19,19 +19,27 @@ const handler: RouteHandlerMethod = async (request, reply) => {
if (!["http:", "https:"].includes(urlObject.protocol))
return reply.status(400).send("Invalid URL");
//* Extract file extension from URL pathname
const pathname = urlObject.pathname;
const extensionMatch = pathname.match(/\.([^./]+)$/);
const extension = extensionMatch?.[1]?.toLowerCase() ?? null;
//* Check if extension is in allowed list
const allowedExtensions = ["png", "jpeg", "jpg", "gif", "webp"];
const hasValidExtension = extension && allowedExtensions.includes(extension);
const hash = crypto.createHash("sha256").update(url).digest("hex");
const existingShortenedUrl = await keyv.get(hash);
void reply.header("Cache-control", "public, max-age=1800");
if (existingShortenedUrl) {
await Promise.all([keyv.set(hash, existingShortenedUrl, 1800), keyv.set(existingShortenedUrl, url, 1800)]);
await Promise.all([keyv.set(hash, existingShortenedUrl, ttl), keyv.set(existingShortenedUrl, url, ttl)]);
return reply.send(process.env.BASE_URL! + existingShortenedUrl);
}
const uniqueId = nanoid(10);
//* Create unique ID with extension if valid, otherwise without extension
const uniqueId = hasValidExtension ? `${nanoid(10)}.${extension}` : nanoid(10);
await Promise.all([keyv.set(hash, uniqueId, 1800), keyv.set(uniqueId, url, 1800)]);
await Promise.all([keyv.set(hash, uniqueId, ttl), keyv.set(uniqueId, url, ttl)]);
return reply.send(process.env.BASE_URL! + uniqueId);
};

View File

@@ -98,4 +98,31 @@ describe("getFullLink", async () => {
expect(result.headers.get("content-type")).toBe("image/png");
expect(Buffer.from(await result.arrayBuffer())).toStrictEqual(imageBuffer);
});
it("should fetch and return PNG image instead of redirecting for URLs with .png extension", async () => {
const testUrl = `https://cdn.rcd.gg/PreMiD/resources/reading.png?v=${"a".repeat(250)}`;
const { body } = await server.inject({
url: `/create/${testUrl}`,
});
expect(body).toStrictEqual(expect.any(String));
vi.spyOn(isInCIDRRange, "default").mockReturnValueOnce(true);
const result = await fetch(`${url}${body}`, {
headers: {
"cf-connecting-ip": "",
},
});
expect(result.status).toBe(200);
expect(result.headers.get("content-type")).toMatch(/^image\//);
//* Should return image data, not redirect
expect(result.headers.get("location")).toBeNull();
//* Verify we got actual image data
const imageBuffer = await result.arrayBuffer();
expect(imageBuffer.byteLength).toBeGreaterThan(0);
});
});

View File

@@ -5,7 +5,7 @@ import type { RouteHandlerMethod } from "fastify";
import isInCIDRRange from "../functions/isInCidRange.js";
import googleCIDRs from "../googleCIDRs.js";
import keyv from "../keyv.js";
import { keyv, ttl } from "../keyv.js";
const handler: RouteHandlerMethod = async (request, reply) => {
/* c8 ignore next 2 */
@@ -31,21 +31,63 @@ const handler: RouteHandlerMethod = async (request, reply) => {
const hash = crypto.createHash("sha256").update(url).digest("hex");
await Promise.all([keyv.set(hash, id, 30 * 60 * 1000), keyv.set(id, url, 30 * 60 * 1000)]);
void reply.header("Cache-control", "public, max-age=1800");
await Promise.all([keyv.set(hash, id, ttl), keyv.set(id, url, ttl)]);
//* If it is not a base64 string, redirect to it
if (!url.startsWith("data:image"))
return reply.redirect(url);
//* If it is a base64 string, decode and return the image
if (url.startsWith("data:image")) {
const image = Buffer.from(
url.replace(/^data:image\/\w+;base64,/, ""),
"base64",
);
const image = Buffer.from(
url.replace(/^data:image\/\w+;base64,/, ""),
"base64",
);
const mime = url.split(";")[0]!.split(":")[1]!;
const mime = url.split(";")[0]!.split(":")[1]!;
return reply.type(mime).send(image);
}
return reply.type(mime).send(image);
//* Check if URL has a valid image extension
const urlObject = new URL(url);
const pathname = urlObject.pathname;
const extensionMatch = pathname.match(/\.([^./]+)$/);
const extension = extensionMatch?.[1]?.toLowerCase() ?? null;
const allowedExtensions = ["png", "jpeg", "jpg", "gif", "webp"];
const hasValidImageExtension = extension && allowedExtensions.includes(extension);
//* If URL has valid image extension, fetch and return the image
if (hasValidImageExtension) {
try {
const response = await fetch(url, {
headers: {
"Accept": "image/*",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept-Encoding": "gzip, deflate, br",
"Cache-Control": "no-cache",
"Pragma": "no-cache",
"Referer": urlObject.origin, //* Set referer to the origin domain to bypass hotlink protection
"Sec-Fetch-Dest": "image",
"Sec-Fetch-Mode": "no-cors",
"Sec-Fetch-Site": "cross-site",
},
});
if (!response.ok) {
return reply.code(404).send("Image not found");
}
const contentType = response.headers.get("content-type") || `image/${extension}`;
const imageBuffer = Buffer.from(await response.arrayBuffer());
return reply.type(contentType).send(imageBuffer);
}
catch {
//* If fetch fails, fall back to redirect
return reply.redirect(url);
}
}
//* For all other URLs, redirect to them
return reply.redirect(url);
};
export default handler;

View File

@@ -1,7 +1,7 @@
{
"name": "@premid/schema-server",
"type": "module",
"version": "1.0.4",
"version": "1.0.11",
"private": true,
"description": "A small service to serve the JSON schemas for PreMiD",
"license": "MPL-2.0",

View File

@@ -0,0 +1,252 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://schemas.premid.app/metadata/1.12",
"title": "Metadata",
"type": "object",
"description": "Metadata that describes a presence.",
"definitions": {
"user": {
"type": "object",
"description": "User information.",
"properties": {
"name": {
"type": "string",
"description": "The name of the user."
},
"id": {
"type": "string",
"description": "The Discord snowflake of the user.",
"pattern": "^\\d+$"
}
},
"additionalProperties": false,
"required": [
"name",
"id"
]
}
},
"properties": {
"$schema": {
"$comment": "This is required otherwise the schema will fail itself when it is applied to a document via $schema. This is optional so that validators that use this schema don't fail if the metadata doesn't have the $schema property.",
"type": "string",
"description": "The metadata schema URL."
},
"author": {
"$ref": "#/definitions/user",
"description": "The author of this presence."
},
"contributors": {
"type": "array",
"description": "Any extra contributors to this presence.",
"items": {
"$ref": "#/definitions/user"
}
},
"service": {
"type": "string",
"description": "The service this presence is for."
},
"altnames": {
"type": "array",
"description": "Alternative names for the service.",
"items": {
"type": "string",
"description": "An alternative name."
},
"minItems": 1
},
"description": {
"type": "object",
"description": "A description of the presence in multiple languages.",
"propertyNames": {
"type": "string",
"description": "The language key. The key must be languagecode(_REGIONCODE).",
"pattern": "^[a-z]{2}(?:_(?:[A-Z]{2}|[0-9]{1,3}))?$"
},
"patternProperties": {
"^[a-z]{2}(?:_(?:[A-Z]{2}|[0-9]{1,3}))?$": {
"type": "string",
"description": "The description of the presence in the key's language."
}
},
"additionalProperties": false,
"required": [
"en"
]
},
"url": {
"type": [
"string",
"array"
],
"description": "The service's website URL, or an array of URLs. Protocols should not be added.",
"pattern": "^(([a-z0-9-]+\\.)*[0-9a-z_-]+(\\.[a-z]+)+|(\\d{1,3}\\.){3}\\d{1,3}|localhost)$",
"items": {
"type": "string",
"description": "One of the service's website URLs.",
"pattern": "^(([a-z0-9-]+\\.)*[0-9a-z_-]+(\\.[a-z]+)+|(\\d{1,3}\\.){3}\\d{1,3}|localhost)$"
},
"minItems": 2
},
"version": {
"type": "string",
"description": "The SemVer version of the presence. Must just be major.minor.patch.",
"pattern": "^\\d+\\.\\d+\\.\\d+$"
},
"apiVersion": {
"type": "integer",
"description": "The Presence System version this Presence supports.",
"minimum": 1,
"maximum": 2
},
"logo": {
"type": "string",
"description": "The logo of the service this presence is for.",
"pattern": "^https?://.+\\.(png|jpe?g|gif|webp)$"
},
"thumbnail": {
"type": "string",
"description": "A thumbnail of the service this presence is for.",
"pattern": "^https?://.+\\.(png|jpe?g|gif|webp)$"
},
"color": {
"type": "string",
"description": "The theme color of the service this presence is for. Must be either a 6 digit or a 3 digit hex code.",
"pattern": "^#([A-Fa-f0-9]{3}){1,2}$"
},
"tags": {
"type": [
"array"
],
"description": "The tags for the presence.",
"items": {
"type": "string",
"description": "A tag.",
"pattern": "^[^A-Z\\s!\"#$%&'()*+,./:;<=>?@\\[\\\\\\]^_`{|}~]+$"
},
"minItems": 1
},
"category": {
"type": "string",
"description": "The category the presence falls under.",
"enum": [
"anime",
"games",
"music",
"socials",
"videos",
"other"
]
},
"iframe": {
"type": "boolean",
"description": "Whether or not the presence should run in IFrames."
},
"readLogs": {
"type": "boolean",
"description": "Whether or not the extension should be reading logs."
},
"regExp": {
"type": "string",
"description": "A regular expression used to match URLs for the presence to inject into."
},
"iFrameRegExp": {
"type": "string",
"description": "A regular expression used to match IFrames for the presence to inject into."
},
"button": {
"type": "boolean",
"description": "Controls whether the presence is automatically added when the extension is installed. For partner presences only."
},
"warning": {
"type": "boolean",
"description": "Shows a warning saying that it requires additional steps for the presence to function correctly."
},
"settings": {
"type": "array",
"description": "An array of settings the user can change in the presence.",
"items": {
"type": "object",
"description": "A setting.",
"properties": {
"id": {
"type": "string",
"description": "The ID of the setting."
},
"title": {
"type": "string",
"description": "The title of the setting. Required only if `multiLanguage` is disabled."
},
"icon": {
"type": "string",
"description": "The icon of the setting. Required only if `multiLanguage` is disabled.",
"pattern": "^fa([bsdrlt]|([-](brands|solid|duotone|regular|light|thin))) fa-[0-9a-z-]+$"
},
"if": {
"type": "object",
"description": "Restrict showing this setting if another setting is the defined value.",
"propertyNames": {
"type": "string",
"description": "The ID of the setting."
},
"patternProperties": {
"": {
"type": [
"string",
"number",
"boolean"
],
"description": "The value of the setting."
}
},
"additionalProperties": false
},
"placeholder": {
"type": "string",
"description": "The placeholder for settings that require input. Shown when the input is empty."
},
"value": {
"type": [
"string",
"number",
"boolean"
],
"description": "The default value of the setting. Not compatible with `values`."
},
"values": {
"type": "array",
"description": "The default values of the setting. Not compatible with `value`.",
"items": {
"type": [
"string",
"number",
"boolean"
],
"description": "The value of the setting."
}
},
"multiLanguage": {
"type": "boolean",
"description": "When true, strings from the `general.json` file are available for use, plus the <service>.json file. False is not allowed."
}
},
"additionalProperties": false
}
}
},
"additionalProperties": false,
"required": [
"author",
"service",
"description",
"url",
"version",
"apiVersion",
"logo",
"thumbnail",
"color",
"tags",
"category"
]
}

View File

@@ -0,0 +1,248 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://schemas.premid.app/metadata/1.13",
"title": "Metadata",
"type": "object",
"description": "Metadata that describes a activity.",
"definitions": {
"user": {
"type": "object",
"description": "User information.",
"properties": {
"name": {
"type": "string",
"description": "The name of the user."
},
"id": {
"type": "string",
"description": "The Discord snowflake of the user.",
"pattern": "^\\d+$"
}
},
"additionalProperties": false,
"required": [
"name",
"id"
]
}
},
"properties": {
"$schema": {
"$comment": "This is required otherwise the schema will fail itself when it is applied to a document via $schema. This is optional so that validators that use this schema don't fail if the metadata doesn't have the $schema property.",
"type": "string",
"description": "The metadata schema URL."
},
"author": {
"$ref": "#/definitions/user",
"description": "The author of this activity."
},
"contributors": {
"type": "array",
"description": "Any extra contributors to this activity.",
"items": {
"$ref": "#/definitions/user"
}
},
"service": {
"type": "string",
"description": "The service this activity is for."
},
"altnames": {
"type": "array",
"description": "Alternative names for the service.",
"items": {
"type": "string",
"description": "An alternative name."
},
"minItems": 1
},
"description": {
"type": "object",
"description": "A description of the activity in multiple languages.",
"propertyNames": {
"type": "string",
"description": "The language key. The key must be languagecode(-regioncode).",
"pattern": "^[a-z]{2,3}(?:-(?:[a-z]{2}|[0-9]{1,3}))?$"
},
"patternProperties": {
"^[a-z]{2,3}(?:-(?:[a-z]{2}|[0-9]{1,3}))?$": {
"type": "string",
"description": "The description of the activity in the key's language."
}
},
"additionalProperties": false,
"required": [
"en"
]
},
"url": {
"type": [
"string",
"array"
],
"description": "The service's website URL, or an array of URLs. Protocols should not be added.",
"pattern": "^(([a-z0-9-]+\\.)*[0-9a-z_-]+(\\.[a-z]+)+|(\\d{1,3}\\.){3}\\d{1,3}|localhost)$",
"items": {
"type": "string",
"description": "One of the service's website URLs.",
"pattern": "^(([a-z0-9-]+\\.)*[0-9a-z_-]+(\\.[a-z]+)+|(\\d{1,3}\\.){3}\\d{1,3}|localhost)$"
},
"minItems": 2
},
"version": {
"type": "string",
"description": "The SemVer version of the activity. Must just be major.minor.patch.",
"pattern": "^\\d+\\.\\d+\\.\\d+$"
},
"apiVersion": {
"type": "integer",
"description": "The Activity System version this activity supports.",
"minimum": 1,
"maximum": 2
},
"logo": {
"type": "string",
"description": "The logo of the service this activity is for.",
"pattern": "^https?://.+\\.(png|jpe?g|gif|webp)$"
},
"thumbnail": {
"type": "string",
"description": "A thumbnail of the service this activity is for.",
"pattern": "^https?://.+\\.(png|jpe?g|gif|webp)$"
},
"color": {
"type": "string",
"description": "The theme color of the service this activity is for. Must be either a 6 digit or a 3 digit hex code.",
"pattern": "^#([A-Fa-f0-9]{3}){1,2}$"
},
"tags": {
"type": [
"array"
],
"description": "The tags for the activity.",
"items": {
"type": "string",
"description": "A tag.",
"pattern": "^[^A-Z\\s!\"#$%&'()*+,./:;<=>?@\\[\\\\\\]^_`{|}~]+$"
},
"minItems": 1
},
"category": {
"type": "string",
"description": "The category the activity falls under.",
"enum": [
"anime",
"games",
"music",
"socials",
"videos",
"other"
]
},
"iframe": {
"type": "boolean",
"description": "Whether or not the activity should run in IFrames."
},
"readLogs": {
"type": "boolean",
"description": "Whether or not the extension should be reading logs."
},
"regExp": {
"type": "string",
"description": "A regular expression used to match URLs for the activity to inject into."
},
"iFrameRegExp": {
"type": "string",
"description": "A regular expression used to match IFrames for the activity to inject into."
},
"settings": {
"type": "array",
"description": "An array of settings the user can change in the activity.",
"items": {
"type": "object",
"description": "A setting.",
"properties": {
"id": {
"type": "string",
"description": "The ID of the setting."
},
"title": {
"type": "string",
"description": "The title of the setting. Required only if `multiLanguage` is disabled."
},
"icon": {
"type": "string",
"description": "The icon of the setting. Required only if `multiLanguage` is disabled.",
"pattern": "^fa([bsdrlt]|([-](brands|solid|duotone|regular|light|thin))) fa-[0-9a-z-]+$"
},
"if": {
"type": "object",
"description": "Restrict showing this setting if another setting is the defined value.",
"propertyNames": {
"type": "string",
"description": "The ID of the setting."
},
"patternProperties": {
"": {
"type": [
"string",
"number",
"boolean"
],
"description": "The value of the setting."
}
},
"additionalProperties": false
},
"placeholder": {
"type": "string",
"description": "The placeholder for settings that require input. Shown when the input is empty."
},
"value": {
"type": [
"string",
"number",
"boolean"
],
"description": "The default value of the setting. Not compatible with `values`."
},
"values": {
"type": "array",
"description": "The default values of the setting. Not compatible with `value`.",
"items": {
"type": [
"string",
"number",
"boolean"
],
"description": "The value of the setting."
}
},
"multiLanguage": {
"type": "boolean",
"description": "When true, strings from the `general.json` file are available for use, plus the <service>.json file. False is not allowed."
}
},
"additionalProperties": false
}
},
"mobile": {
"type": "boolean",
"description": "Whether or not the activity has support for mobile devices."
}
},
"additionalProperties": false,
"required": [
"author",
"service",
"description",
"url",
"version",
"apiVersion",
"logo",
"thumbnail",
"color",
"tags",
"category"
]
}

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