Compare commits

...

19 Commits

Author SHA1 Message Date
Bas950
0785b49ced chore: release v1.2.2 2025-10-13 23:41:23 +02:00
Bas950
1e1e4f88b4 chore: remove cache headers 2025-10-13 23:41:03 +02:00
Bas950
9a5dbd3428 chore: release v1.2.1 2025-09-16 11:26:05 +02:00
Bas950
596b7fc759 fix: return image directly 2025-09-16 11:25:52 +02:00
Bas950
de305d76e6 chore: release v1.2.0 2025-09-15 11:44:10 +02:00
Bas950
7f520d775d feat: add file extension 2025-09-15 11:43:24 +02:00
Bas950
3df13b6147 chore: release v1.0.10 2025-07-15 17:19:56 +02:00
Bas950
1106c77649 feat: schema 1.15 2025-07-15 17:19:30 +02:00
Bas950
f121006db0 chore: release v1.0.9 2025-06-04 10:52:21 +02:00
Bas950
459d567a4b feat: v1.14 of schema 2025-06-04 10:51:57 +02:00
veryCrunchy
10278567fd chore: add Social Media Manager role and color to constants (#1084)
* chore: add Social Media Manager role and color to constants

* chore: bump version
2025-04-23 14:52:27 +02:00
Bas950
038b71326c chore: release v1.0.8 2025-01-27 10:03:59 +01:00
Bas950
4ecd1b6c0b chore: lint 2025-01-27 10:03:28 +01:00
Bas950
d37ca76318 chore: release v1.0.7 2025-01-27 10:02:50 +01:00
Bas950
4367f3f679 chore: add mobile and update descriptions 2025-01-27 10:02:42 +01:00
Bas950
9920bdf83e chore: release v1.0.6 2025-01-26 18:32:20 +01:00
Bas950
e4c9719b44 feat(schema-server): add v1.13 2025-01-26 18:32:01 +01:00
Florian Metz
19e249ce9a chore: release v1.0.7 2025-01-10 00:33:53 +01:00
veryCrunchy
3338915f4e chore(bot): rename presence to activity (#1077) 2025-01-10 00:30:13 +01:00
18 changed files with 964 additions and 55 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "@premid/discord-bot",
"type": "module",
"version": "1.0.6",
"version": "1.0.8",
"private": true,
"description": "PreMiD's discord bot",
"license": "MPL-2.0",

View File

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

View File

@@ -36,6 +36,7 @@ export const roles = {
SUPPORT_AGENT: "566417964820070421",
MARKETING_DIRECTOR: "673681900476432387",
LOCALIZATION_MANAGER: "811262682408943616",
SOCIALS_MANAGER: "1027665964684300358",
REPRESENTATIVE: "691384256672563332",
CONTRIBUTOR: "1032759805732978708",
PATRON: "515874214750715904",
@@ -58,6 +59,7 @@ export const roleColors = {
SUPPORT_AGENT: "#D67118",
MARKETING_DIRECTOR: "#3BA576",
LOCALIZATION_MANAGER: "#3BA576",
SOCIALS_MANAGER: "#1abc9c",
REPRESENTATIVE: "#3BA576",
CONTRIBUTOR: "#EB459E",
PATRON: "#E5472F",

View File

@@ -7,7 +7,7 @@ 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";
import { updateActivityList } from "./util/activityList.js";
Sentry.init({
integrations: [
@@ -39,7 +39,7 @@ catch (error) {
try {
await connect(processEnv.DATABASE_URL, { appName: "PreMiD Discord Bot" });
logger.info("Successfully connected to database");
await updatePresenceList();
await updateActivityList();
logger.info("Successfully updated presence list");
}
catch (error) {
@@ -85,5 +85,5 @@ setInterval(async () => {
}, 60000);
setInterval(() => {
updatePresenceList();
updateActivityList();
}, 1000 * 60 * 5);

View File

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

View File

@@ -1,12 +1,12 @@
import type { ActivitiesOptions } from "discord.js";
import { ActivityType } from "discord.js";
import { getPresenceList } from "./presenceList.js";
import { getActivityList } from "./activityList.js";
export function getActivity({ previous }: {
previous?: string;
}): ActivitiesOptions {
const presenceList = getPresenceList();
const statuses = presenceList.filter(status => status.service !== previous);
const activityList = getActivityList();
const statuses = activityList.filter(status => status.service !== previous);
const selectedStatus = statuses[Math.floor(Math.random() * statuses.length)]!;
return {

View File

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

View File

@@ -33,7 +33,6 @@ 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);
}
@@ -42,7 +41,6 @@ const handler: RouteHandlerMethod = async (request, reply) => {
await keyv.set(hash, uniqueId, 30 * 60 * 1000);
await keyv.set(uniqueId, body, 30 * 60 * 1000);
void reply.header("Cache-control", `public, max-age=${(30 * 60).toString()}`);
return reply.send(process.env.BASE_URL! + uniqueId);
};

View File

@@ -37,7 +37,6 @@ 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);
}
@@ -48,7 +47,6 @@ const handler: RouteHandlerMethod = async (request, reply) => {
keyv.set(uniqueId, body, 30 * 60 * 1000),
]);
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

@@ -19,17 +19,25 @@ 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)]);
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)]);

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

@@ -32,20 +32,50 @@ 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");
//* 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);
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.5",
"version": "1.0.10",
"private": true,
"description": "A small service to serve the JSON schemas for PreMiD",
"license": "MPL-2.0",

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"
]
}

View File

@@ -0,0 +1,252 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://schemas.premid.app/metadata/1.14",
"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."
},
"description": {
"type": "string",
"description": "The description of the setting. Only applicable 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"
]
}

View File

@@ -0,0 +1,256 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://schemas.premid.app/metadata/1.15",
"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."
},
"allowURLOverrides": {
"type": "boolean",
"description": "Whether or not the activity should allow URL overrides."
},
"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."
},
"description": {
"type": "string",
"description": "The description of the setting. Only applicable 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"
]
}

View File

@@ -15,6 +15,8 @@ const staff = computed(() => {
"514546359865442304",
//* Support
"566417964820070421",
//* Social Media Manager
"1027665964684300358",
].includes(item?.user?.roleId || ""),
)
.sort(sortContributors) || []