generated from nhcarrigan/template
feat: build out initial prototype (#1)
Some checks failed
Node.js CI / Lint and Test (push) Has been cancelled
Some checks failed
Node.js CI / Lint and Test (push) Has been cancelled
### Explanation _No response_ ### Issue _No response_ ### Attestations - [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/) - [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/). - [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/). ### Dependencies - [ ] I have pinned the dependencies to a specific patch version. ### Style - [ ] I have run the linter and resolved any errors. - [ ] My pull request uses an appropriate title, matching the conventional commit standards. - [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request. ### Tests - [ ] My contribution adds new code, and I have added tests to cover it. - [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes. - [ ] All new and existing tests pass locally with my changes. - [ ] Code coverage remains at or above the configured threshold. ### Documentation _No response_ ### Versioning _No response_ Reviewed-on: #1 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #1.
This commit is contained in:
38
.gitea/workflows/ci.yml
Normal file
38
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
name: Node.js CI
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: Lint and Test
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Source Files
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Use Node.js v24
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v2
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Verify Build
|
||||||
|
run: pnpm run build
|
||||||
|
|
||||||
|
- name: Lint Source Files
|
||||||
|
run: pnpm run lint
|
||||||
|
|
||||||
|
- name: Run Tests
|
||||||
|
run: pnpm run test
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
prod
|
||||||
|
node_modules
|
||||||
6
.vscode/settings.json
vendored
Normal file
6
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": "explicit"
|
||||||
|
},
|
||||||
|
"eslint.validate": ["typescript"]
|
||||||
|
}
|
||||||
52
commandJson.js
Normal file
52
commandJson.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { ApplicationIntegrationType, InteractionContextType, SlashCommandBuilder } from "discord.js";
|
||||||
|
|
||||||
|
const about = new SlashCommandBuilder()
|
||||||
|
.setName("about")
|
||||||
|
.setDescription("Get information about this application.")
|
||||||
|
.setContexts([InteractionContextType.Guild, InteractionContextType.BotDM, InteractionContextType.PrivateChannel])
|
||||||
|
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall, ApplicationIntegrationType.UserInstall]);
|
||||||
|
|
||||||
|
const add = new SlashCommandBuilder()
|
||||||
|
.setName("add")
|
||||||
|
.setDescription("Add a new short URL.")
|
||||||
|
.setContexts([InteractionContextType.Guild, InteractionContextType.BotDM, InteractionContextType.PrivateChannel])
|
||||||
|
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall, ApplicationIntegrationType.UserInstall])
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("slug")
|
||||||
|
.setDescription("The slug for the new short URL.")
|
||||||
|
.setRequired(true)
|
||||||
|
.setMaxLength(100),
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("url")
|
||||||
|
.setDescription("The URL to shorten.")
|
||||||
|
.setRequired(true)
|
||||||
|
.setMaxLength(2000),
|
||||||
|
)
|
||||||
|
|
||||||
|
const remove = new SlashCommandBuilder()
|
||||||
|
.setName("remove")
|
||||||
|
.setDescription("Remove a short URL.")
|
||||||
|
.setContexts([InteractionContextType.Guild, InteractionContextType.BotDM, InteractionContextType.PrivateChannel])
|
||||||
|
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall, ApplicationIntegrationType.UserInstall])
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName("slug")
|
||||||
|
.setDescription("The slug for the short URL to remove.")
|
||||||
|
.setRequired(true)
|
||||||
|
.setMaxLength(100),
|
||||||
|
)
|
||||||
|
const list = new SlashCommandBuilder()
|
||||||
|
.setName("list")
|
||||||
|
.setDescription("List your short URLs.")
|
||||||
|
.setContexts([InteractionContextType.Guild, InteractionContextType.BotDM, InteractionContextType.PrivateChannel])
|
||||||
|
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall, ApplicationIntegrationType.UserInstall])
|
||||||
|
|
||||||
|
console.log(JSON.stringify([
|
||||||
|
about.toJSON(),
|
||||||
|
add.toJSON(),
|
||||||
|
remove.toJSON(),
|
||||||
|
list.toJSON(),
|
||||||
|
]));
|
||||||
5
eslint.config.js
Normal file
5
eslint.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import NaomisConfig from '@nhcarrigan/eslint-config';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
...NaomisConfig,
|
||||||
|
]
|
||||||
31
package.json
Normal file
31
package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "lynira",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"description": "Link shortening service managed via a Discord bot",
|
||||||
|
"main": "index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"lint": "eslint ./src --max-warnings 0",
|
||||||
|
"build": "prisma generate && tsc",
|
||||||
|
"start": "op run --env-file=./prod.env -- node ./prod/index.js",
|
||||||
|
"test": "echo 'No tests yet' && exit 0"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"packageManager": "pnpm@10.14.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@nhcarrigan/eslint-config": "5.2.0",
|
||||||
|
"@nhcarrigan/typescript-config": "4.0.0",
|
||||||
|
"@types/node": "24.2.1",
|
||||||
|
"eslint": "9.33.0",
|
||||||
|
"prisma": "6.13.0",
|
||||||
|
"typescript": "5.9.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nhcarrigan/logger": "1.0.0",
|
||||||
|
"@prisma/client": "6.13.0",
|
||||||
|
"discord.js": "14.21.0",
|
||||||
|
"fastify": "5.5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
4981
pnpm-lock.yaml
generated
Normal file
4981
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
prisma/schema.prisma
Normal file
22
prisma/schema.prisma
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "mongodb"
|
||||||
|
url = env("MONGO_URI")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Links {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
userId String
|
||||||
|
url String
|
||||||
|
slug String @unique
|
||||||
|
deleted Boolean @default(false)
|
||||||
|
|
||||||
|
@@unique([userId, url], map: "userId_url")
|
||||||
|
@@index(userId)
|
||||||
|
}
|
||||||
3
prod.env
Normal file
3
prod.env
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
MONGO_URI="op://Environment Variables - Naomi/Lynira/mongo uri"
|
||||||
|
BOT_TOKEN="op://Environment Variables - Naomi/Lynira/discord token"
|
||||||
|
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
|
||||||
89
src/commands/about.ts
Normal file
89
src/commands/about.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
TextDisplayBuilder,
|
||||||
|
SeparatorBuilder,
|
||||||
|
SeparatorSpacingSize,
|
||||||
|
ContainerBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonStyle,
|
||||||
|
ActionRowBuilder,
|
||||||
|
MessageFlags,
|
||||||
|
} from "discord.js";
|
||||||
|
import { errorHandler } from "../utils/errorHandler.js";
|
||||||
|
import type { Command } from "../interfaces/command.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the `/about` command interaction.
|
||||||
|
* @param _lynira - Lynira's Discord instance (unused).
|
||||||
|
* @param interaction - The command interaction payload from Discord.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line max-lines-per-function -- It's mostly components.
|
||||||
|
export const about: Command = async(_lynira, interaction) => {
|
||||||
|
try {
|
||||||
|
const components = [
|
||||||
|
new ContainerBuilder().
|
||||||
|
addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent("# About Lynira"),
|
||||||
|
).
|
||||||
|
addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(
|
||||||
|
// eslint-disable-next-line stylistic/max-len -- Big boi string.
|
||||||
|
"Hi there~! I am Lynira, a bot that allows you to create and delete short URLs. Links will be available under the `lynira.link` domain.",
|
||||||
|
),
|
||||||
|
).
|
||||||
|
addSeparatorComponents(
|
||||||
|
new SeparatorBuilder().
|
||||||
|
setSpacing(SeparatorSpacingSize.Small).
|
||||||
|
setDivider(true),
|
||||||
|
).
|
||||||
|
addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent("## What can I do?"),
|
||||||
|
).
|
||||||
|
addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(
|
||||||
|
// eslint-disable-next-line stylistic/max-len -- Big boi string.
|
||||||
|
"To get started, you will need to subscribe to our service. We offer four tiers, each with different limits on the number of short URLs you can create. Once you have subscribed, you can use `/add`, `/delete`, and `/list` to manage your short URLs.",
|
||||||
|
),
|
||||||
|
).
|
||||||
|
addSeparatorComponents(
|
||||||
|
new SeparatorBuilder().
|
||||||
|
setSpacing(SeparatorSpacingSize.Small).
|
||||||
|
setDivider(true),
|
||||||
|
).
|
||||||
|
addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent("## What if I need help?"),
|
||||||
|
).
|
||||||
|
addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(
|
||||||
|
// eslint-disable-next-line stylistic/max-len -- Big boi string.
|
||||||
|
"My deepest apologies if I have made a mistake! Please reach out to us in our Discord server or on the forum, and we will do our best to assist you.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
new ButtonBuilder().
|
||||||
|
setStyle(ButtonStyle.Link).
|
||||||
|
setLabel("Discord Server").
|
||||||
|
setURL("https://chat.nhcarrigan.com"),
|
||||||
|
new ButtonBuilder().
|
||||||
|
setStyle(ButtonStyle.Link).
|
||||||
|
setLabel("Forum").
|
||||||
|
setURL("https://forum.nhcarrigan.com"),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
await interaction.editReply({
|
||||||
|
components: components,
|
||||||
|
flags: MessageFlags.IsComponentsV2,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await errorHandler(error, "about command");
|
||||||
|
await interaction.editReply({
|
||||||
|
content:
|
||||||
|
// eslint-disable-next-line stylistic/max-len -- Big boi string.
|
||||||
|
"An error occurred while processing your request. Please try again later.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
91
src/commands/add.ts
Normal file
91
src/commands/add.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { reservedSlugs } from "../config/reservedSlugs.js";
|
||||||
|
import { getEntitlement } from "../modules/getEntitlement.js";
|
||||||
|
import { getSkuLimit } from "../modules/getSkuLimit.js";
|
||||||
|
import { sendUnentitledResponse } from "../modules/sendUnintitledResponse.js";
|
||||||
|
import { errorHandler } from "../utils/errorHandler.js";
|
||||||
|
import { logger } from "../utils/logger.js";
|
||||||
|
import type { Command } from "../interfaces/command.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the `/add` command interaction.
|
||||||
|
* @param lynira - Lynira's Discord instance.
|
||||||
|
* @param interaction - The command interaction payload from Discord.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line max-lines-per-function, max-statements -- This is a larger handler.
|
||||||
|
export const add: Command = async(lynira, interaction) => {
|
||||||
|
try {
|
||||||
|
const subscription = await getEntitlement(lynira, interaction.user.id);
|
||||||
|
if (!subscription) {
|
||||||
|
await sendUnentitledResponse(interaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const slug = interaction.options.getString("slug", true);
|
||||||
|
if (reservedSlugs.includes(slug)) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "This slug is reserved and cannot be used.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = interaction.options.getString("url", true);
|
||||||
|
const isValidUrl = /^https?:\/\/(?:[\da-z-]+\.)+[a-z]{2,}(?:\/\S*)?$/i.test(
|
||||||
|
url,
|
||||||
|
);
|
||||||
|
if (!isValidUrl) {
|
||||||
|
await logger.log("debug", `Provided URL did not match expected pattern: ${url}`);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "The provided URL is not valid. Please provide a valid URL.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const slugExists = await lynira.db.links.findFirst({
|
||||||
|
where: {
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (slugExists) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "This slug is already in use. Please choose a different one.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const count = await lynira.db.links.count({
|
||||||
|
where: {
|
||||||
|
deleted: false,
|
||||||
|
userId: interaction.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const limit = getSkuLimit(subscription.skuId);
|
||||||
|
if (count >= limit) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `You have reached your limit of ${limit.toString()} short URLs.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await lynira.db.links.create({
|
||||||
|
data: {
|
||||||
|
slug: slug,
|
||||||
|
url: url,
|
||||||
|
userId: interaction.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Short URL created. Visit <https://lynira.link/${slug}> to access it. Please note that for the safety of our users, you will not be able to edit this short link.`,
|
||||||
|
});
|
||||||
|
await logger.log(
|
||||||
|
"info",
|
||||||
|
`User ${interaction.user.id} created a short URL with slug "${slug}" pointing to "${url}".`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
await errorHandler(error, "add command");
|
||||||
|
await interaction.editReply({
|
||||||
|
content:
|
||||||
|
// eslint-disable-next-line stylistic/max-len -- Big boi string.
|
||||||
|
"An error occurred while processing your request. Please try again later.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
74
src/commands/list.ts
Normal file
74
src/commands/list.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
ContainerBuilder,
|
||||||
|
TextDisplayBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonStyle,
|
||||||
|
ActionRowBuilder,
|
||||||
|
MessageFlags,
|
||||||
|
} from "discord.js";
|
||||||
|
import { getEntitlement } from "../modules/getEntitlement.js";
|
||||||
|
import { getLinkComponents } from "../modules/getLinkComponents.js";
|
||||||
|
import { getSkuLimit } from "../modules/getSkuLimit.js";
|
||||||
|
import { sendUnentitledResponse } from "../modules/sendUnintitledResponse.js";
|
||||||
|
import { errorHandler } from "../utils/errorHandler.js";
|
||||||
|
import type { Command } from "../interfaces/command.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the `/list` command interaction.
|
||||||
|
* @param lynira - Lynira's Discord instance.
|
||||||
|
* @param interaction - The command interaction payload from Discord.
|
||||||
|
*/
|
||||||
|
export const list: Command = async(lynira, interaction) => {
|
||||||
|
try {
|
||||||
|
const subscription = await getEntitlement(lynira, interaction.user.id);
|
||||||
|
if (!subscription) {
|
||||||
|
await sendUnentitledResponse(interaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const urls = await lynira.db.links.findMany({
|
||||||
|
where: {
|
||||||
|
deleted: false,
|
||||||
|
userId: interaction.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const components: Array<ContainerBuilder | ActionRowBuilder<ButtonBuilder>>
|
||||||
|
= getLinkComponents(urls);
|
||||||
|
components.push(
|
||||||
|
new ContainerBuilder().addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(
|
||||||
|
`You are currently using ${urls.length.toString()} short URL${
|
||||||
|
urls.length === 1
|
||||||
|
? ""
|
||||||
|
: "s"
|
||||||
|
} out of ${getSkuLimit(subscription.skuId).toString()}.`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
new ButtonBuilder().
|
||||||
|
setStyle(ButtonStyle.Link).
|
||||||
|
setLabel("Discord Server").
|
||||||
|
setURL("https://chat.nhcarrigan.com"),
|
||||||
|
new ButtonBuilder().
|
||||||
|
setStyle(ButtonStyle.Link).
|
||||||
|
setLabel("Forum").
|
||||||
|
setURL("https://forum.nhcarrigan.com"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await interaction.editReply({
|
||||||
|
components: components,
|
||||||
|
flags: MessageFlags.IsComponentsV2,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await errorHandler(error, "list command");
|
||||||
|
await interaction.editReply({
|
||||||
|
content:
|
||||||
|
// eslint-disable-next-line stylistic/max-len -- Big boi string.
|
||||||
|
"An error occurred while processing your request. Please try again later.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
53
src/commands/remove.ts
Normal file
53
src/commands/remove.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { getEntitlement } from "../modules/getEntitlement.js";
|
||||||
|
import { sendUnentitledResponse } from "../modules/sendUnintitledResponse.js";
|
||||||
|
import { errorHandler } from "../utils/errorHandler.js";
|
||||||
|
import { logger } from "../utils/logger.js";
|
||||||
|
import type { Command } from "../interfaces/command.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the `/remove` command interaction.
|
||||||
|
* @param lynira - Lynira's Discord instance.
|
||||||
|
* @param interaction - The command interaction payload from Discord.
|
||||||
|
*/
|
||||||
|
export const remove: Command = async(lynira, interaction) => {
|
||||||
|
try {
|
||||||
|
const subscription = await getEntitlement(lynira, interaction.user.id);
|
||||||
|
if (!subscription) {
|
||||||
|
await sendUnentitledResponse(interaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const slug = interaction.options.getString("slug", true);
|
||||||
|
const slugExists = await lynira.db.links.findFirst({ where: {
|
||||||
|
deleted: false,
|
||||||
|
slug: slug,
|
||||||
|
userId: interaction.user.id,
|
||||||
|
} });
|
||||||
|
if (!slugExists) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "This slug does not exist, or does not belong to you.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await lynira.db.links.delete({
|
||||||
|
where: {
|
||||||
|
id: slugExists.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Short URL <https://lynira.link/${slug}> has been removed. Please note that for the safety of our users, this slug will not be available for reuse.`,
|
||||||
|
});
|
||||||
|
await logger.log("info", `User ${interaction.user.id} removed a short URL with slug "${slug}".`);
|
||||||
|
} catch (error) {
|
||||||
|
await errorHandler(error, "remove command");
|
||||||
|
await interaction.editReply({
|
||||||
|
content:
|
||||||
|
// eslint-disable-next-line stylistic/max-len -- Big boi string.
|
||||||
|
"An error occurred while processing your request. Please try again later.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
23
src/config/handlers.ts
Normal file
23
src/config/handlers.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { about } from "../commands/about.js";
|
||||||
|
import { add } from "../commands/add.js";
|
||||||
|
import { list } from "../commands/list.js";
|
||||||
|
import type { Command } from "../interfaces/command.js";
|
||||||
|
|
||||||
|
const defaultHandler: Command = async(_lynira, interaction) => {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "This command is not implemented yet.",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handlers: { _default: Command } & Record<string, Command> = {
|
||||||
|
_default: defaultHandler,
|
||||||
|
about: about,
|
||||||
|
add: add,
|
||||||
|
list: list,
|
||||||
|
};
|
||||||
12
src/config/reservedSlugs.ts
Normal file
12
src/config/reservedSlugs.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const reservedSlugs = [
|
||||||
|
"error",
|
||||||
|
"unsub",
|
||||||
|
"overlimit",
|
||||||
|
"404",
|
||||||
|
];
|
||||||
36
src/index.ts
Normal file
36
src/index.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { Client, Events } from "discord.js";
|
||||||
|
import { processCommand } from "./modules/processCommand.js";
|
||||||
|
import { instantiateServer } from "./server/serve.js";
|
||||||
|
import { logger } from "./utils/logger.js";
|
||||||
|
import type { Lynira } from "./interfaces/lynira.js";
|
||||||
|
|
||||||
|
const lynira: Lynira = {
|
||||||
|
db: new PrismaClient(),
|
||||||
|
discord: new Client({ intents: [] }),
|
||||||
|
};
|
||||||
|
|
||||||
|
lynira.discord.once("ready", () => {
|
||||||
|
void logger.log("debug", `Logged in as ${lynira.discord.user?.tag ?? "unknown"}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
lynira.discord.on("error", (error) => {
|
||||||
|
void logger.error("Discord client error", error);
|
||||||
|
});
|
||||||
|
|
||||||
|
lynira.discord.on(Events.InteractionCreate, (interaction) => {
|
||||||
|
if (interaction.isChatInputCommand()) {
|
||||||
|
void processCommand(lynira, interaction);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await lynira.db.$connect();
|
||||||
|
await lynira.discord.login(process.env.BOT_TOKEN);
|
||||||
|
|
||||||
|
instantiateServer(lynira);
|
||||||
12
src/interfaces/command.ts
Normal file
12
src/interfaces/command.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import type { Lynira } from "./lynira.js";
|
||||||
|
import type { ChatInputCommandInteraction } from "discord.js";
|
||||||
|
|
||||||
|
export type Command = (
|
||||||
|
lynira: Lynira,
|
||||||
|
interaction: ChatInputCommandInteraction
|
||||||
|
)=> Promise<void>;
|
||||||
13
src/interfaces/lynira.ts
Normal file
13
src/interfaces/lynira.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PrismaClient } from "@prisma/client";
|
||||||
|
import type { Client } from "discord.js";
|
||||||
|
|
||||||
|
export interface Lynira {
|
||||||
|
discord: Client;
|
||||||
|
db: PrismaClient;
|
||||||
|
}
|
||||||
14
src/interfaces/skus.ts
Normal file
14
src/interfaces/skus.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
/* eslint-disable @typescript-eslint/naming-convention -- enum conventions are caps */
|
||||||
|
|
||||||
|
export enum SKU {
|
||||||
|
PERSONAL = "1404602103434973186",
|
||||||
|
PROFESSIONAL = "1404602584404328573",
|
||||||
|
BUSINESS = "1404602909370613931",
|
||||||
|
ENTERPRISE = "1404653451224416317",
|
||||||
|
ORGANISATION = "1404603245963513898",
|
||||||
|
}
|
||||||
51
src/modules/getEntitlement.ts
Normal file
51
src/modules/getEntitlement.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type Entitlement, EntitlementType } from "discord.js";
|
||||||
|
import { SKU } from "../interfaces/skus.js";
|
||||||
|
import type { Lynira } from "../interfaces/lynira.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches an entitlement for a user. Only looks at the first ACTIVE entitlement.
|
||||||
|
* @param lynira - Lynira's Discord instance.
|
||||||
|
* @param userId - The ID of the user to fetch the entitlement for.
|
||||||
|
* @returns The entitlement if found, or null if not found.
|
||||||
|
*/
|
||||||
|
export const getEntitlement = async(
|
||||||
|
lynira: Lynira,
|
||||||
|
userId: string,
|
||||||
|
): Promise<Entitlement | null> => {
|
||||||
|
if (userId === "465650873650118659") {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Mock entitlement for Naomi.
|
||||||
|
return {
|
||||||
|
applicationId: lynira.discord.application?.id ?? "",
|
||||||
|
consumed: false,
|
||||||
|
deleted: false,
|
||||||
|
endsAt: null,
|
||||||
|
endsTimestamp: null,
|
||||||
|
guild: null,
|
||||||
|
guildId: null,
|
||||||
|
id: "enterprise-entitlement-id",
|
||||||
|
isActive: () => {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
skuId: SKU.ENTERPRISE,
|
||||||
|
startsAt: null,
|
||||||
|
startsTimestamp: null,
|
||||||
|
type: EntitlementType.ApplicationSubscription,
|
||||||
|
userId: "465650873650118659",
|
||||||
|
} as Entitlement;
|
||||||
|
}
|
||||||
|
const entitlements = await lynira.discord.application?.entitlements.fetch({
|
||||||
|
excludeDeleted: true,
|
||||||
|
excludeEnded: true,
|
||||||
|
user: userId,
|
||||||
|
}).catch(() => {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return entitlements?.first() ?? null;
|
||||||
|
};
|
||||||
33
src/modules/getLinkComponents.ts
Normal file
33
src/modules/getLinkComponents.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ContainerBuilder, TextDisplayBuilder } from "discord.js";
|
||||||
|
import type { Links } from "@prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an array of components for displaying users' short URLs.
|
||||||
|
* @param links - The array of links to display.
|
||||||
|
* @returns An array of ContainerBuilder components.
|
||||||
|
*/
|
||||||
|
export const getLinkComponents
|
||||||
|
= (links: Array<Links>): Array<ContainerBuilder> => {
|
||||||
|
const components: Array<ContainerBuilder> = [];
|
||||||
|
while (links.length > 0) {
|
||||||
|
const slice = links.splice(0, 50);
|
||||||
|
components.push(
|
||||||
|
new ContainerBuilder().
|
||||||
|
addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(
|
||||||
|
slice.map((link) => {
|
||||||
|
return `- \`${link.slug}\`: ${link.url}`;
|
||||||
|
}).
|
||||||
|
join("\n"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return components;
|
||||||
|
};
|
||||||
32
src/modules/getSkuLimit.ts
Normal file
32
src/modules/getSkuLimit.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison -- We want this function to accept arbitrary SKU IDs. */
|
||||||
|
|
||||||
|
import { SKU } from "../interfaces/skus.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the URL limit for a given SKU.
|
||||||
|
* @param sku - The SKU to check.
|
||||||
|
* @returns The URL limit for the SKU. Returns 0 if the SKU is not recognized.
|
||||||
|
*/
|
||||||
|
export const getSkuLimit = (sku: SKU | string): number => {
|
||||||
|
if (sku === SKU.PERSONAL) {
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
if (sku === SKU.PROFESSIONAL) {
|
||||||
|
return 25;
|
||||||
|
}
|
||||||
|
if (sku === SKU.BUSINESS) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
if (sku === SKU.ORGANISATION) {
|
||||||
|
return 250;
|
||||||
|
}
|
||||||
|
if (sku === SKU.ENTERPRISE) {
|
||||||
|
return 1000;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
23
src/modules/processCommand.ts
Normal file
23
src/modules/processCommand.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MessageFlags } from "discord.js";
|
||||||
|
import { handlers } from "../config/handlers.js";
|
||||||
|
import type { Command } from "../interfaces/command.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a command interaction.
|
||||||
|
* @param lynira - The Lynira instance.
|
||||||
|
* @param interaction - The interaction to process.
|
||||||
|
*/
|
||||||
|
export const processCommand: Command = async(lynira, interaction) => {
|
||||||
|
await interaction.deferReply({
|
||||||
|
flags: [ MessageFlags.Ephemeral ],
|
||||||
|
});
|
||||||
|
const { commandName } = interaction;
|
||||||
|
// eslint-disable-next-line no-underscore-dangle -- Accessing private property for command handler.
|
||||||
|
await (handlers[commandName] ?? handlers._default)(lynira, interaction);
|
||||||
|
};
|
||||||
48
src/modules/sendUnintitledResponse.ts
Normal file
48
src/modules/sendUnintitledResponse.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ActionRowBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonStyle,
|
||||||
|
MessageFlags,
|
||||||
|
TextDisplayBuilder,
|
||||||
|
type ChatInputCommandInteraction,
|
||||||
|
} from "discord.js";
|
||||||
|
import { SKU } from "../interfaces/skus.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Responds with a default image and a button to subscribe.
|
||||||
|
* @param interaction - The interaction object from Discord.
|
||||||
|
*/
|
||||||
|
export const sendUnentitledResponse = async(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
): Promise<void> => {
|
||||||
|
const components = [
|
||||||
|
new TextDisplayBuilder().setContent(
|
||||||
|
// eslint-disable-next-line stylistic/max-len -- Big boi string.
|
||||||
|
"Hmm, you do not seem to have an active subscription. If you wish to continue using my services, you will need to rectify that.",
|
||||||
|
),
|
||||||
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
new ButtonBuilder().
|
||||||
|
setStyle(ButtonStyle.Premium).
|
||||||
|
setSKUId(SKU.PERSONAL),
|
||||||
|
new ButtonBuilder().
|
||||||
|
setStyle(ButtonStyle.Premium).
|
||||||
|
setSKUId(SKU.PROFESSIONAL),
|
||||||
|
new ButtonBuilder().
|
||||||
|
setStyle(ButtonStyle.Premium).
|
||||||
|
setSKUId(SKU.BUSINESS),
|
||||||
|
new ButtonBuilder().
|
||||||
|
setStyle(ButtonStyle.Premium).
|
||||||
|
setSKUId(SKU.ENTERPRISE),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
await interaction.editReply({
|
||||||
|
components: components,
|
||||||
|
flags: [ MessageFlags.IsComponentsV2 ],
|
||||||
|
});
|
||||||
|
};
|
||||||
43
src/server/html/error.ts
Normal file
43
src/server/html/error.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const error = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Lynira</title>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="description" content="Link shortener service managed via a Discord bot." />
|
||||||
|
<script src="https://cdn.nhcarrigan.com/headers/index.js" async defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Oh dear!</h1>
|
||||||
|
<img src="https://cdn.nhcarrigan.com/new-avatars/lynira.png" width="250" alt="Lynira" />
|
||||||
|
<section>
|
||||||
|
<p>Something went wrong while trying to redirect you! Please try again later.</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Need Help?</h2>
|
||||||
|
<p>
|
||||||
|
<a href="https://git.nhcarrigan.com/nhcarrigan/lynira">
|
||||||
|
<i class="fa-solid fa-code"></i> Source Code
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="https://docs.nhcarrigan.com/">
|
||||||
|
<i class="fa-solid fa-book"></i> Documentation
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="https://chat.nhcarrigan.com">
|
||||||
|
<i class="fa-solid fa-circle-info"></i> Support
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
43
src/server/html/fourOhFour.ts
Normal file
43
src/server/html/fourOhFour.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const fourOhFour = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Lynira</title>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="description" content="Link shortener service managed via a Discord bot." />
|
||||||
|
<script src="https://cdn.nhcarrigan.com/headers/index.js" async defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Oh no!</h1>
|
||||||
|
<img src="https://cdn.nhcarrigan.com/new-avatars/lynira.png" width="250" alt="Lynira" />
|
||||||
|
<section>
|
||||||
|
<p>It looks like that link is no longer registered! You should ask the person who shared it with you for an updated URL.</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Need Help?</h2>
|
||||||
|
<p>
|
||||||
|
<a href="https://git.nhcarrigan.com/nhcarrigan/lynira">
|
||||||
|
<i class="fa-solid fa-code"></i> Source Code
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="https://docs.nhcarrigan.com/">
|
||||||
|
<i class="fa-solid fa-book"></i> Documentation
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="https://chat.nhcarrigan.com">
|
||||||
|
<i class="fa-solid fa-circle-info"></i> Support
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
46
src/server/html/home.ts
Normal file
46
src/server/html/home.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const home = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Lynira</title>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="description" content="Link shortener service managed via a Discord bot." />
|
||||||
|
<script src="https://cdn.nhcarrigan.com/headers/index.js" async defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Lynira</h1>
|
||||||
|
<img src="https://cdn.nhcarrigan.com/new-avatars/lynira.png" width="250" alt="Lynira" />
|
||||||
|
<section>
|
||||||
|
<p>Link shortener service managed via a Discord bot.</p>
|
||||||
|
<a href="https://discord.com/oauth2/authorize?client_id=1404593859656417320" class="social-button discord-button" style="display: inline-block; background-color: #5865F2; color: white; padding: 10px 20px; text-decoration: none; border-radius: 4px; margin: 5px;">
|
||||||
|
<i class="fab fa-discord"></i> Add to Discord
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Links</h2>
|
||||||
|
<p>
|
||||||
|
<a href="https://git.nhcarrigan.com/nhcarrigan/lynira">
|
||||||
|
<i class="fa-solid fa-code"></i> Source Code
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="https://docs.nhcarrigan.com/">
|
||||||
|
<i class="fa-solid fa-book"></i> Documentation
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="https://chat.nhcarrigan.com">
|
||||||
|
<i class="fa-solid fa-circle-info"></i> Support
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
43
src/server/html/overlimit.ts
Normal file
43
src/server/html/overlimit.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const overlimit = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Lynira</title>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="description" content="Link shortener service managed via a Discord bot." />
|
||||||
|
<script src="https://cdn.nhcarrigan.com/headers/index.js" async defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Oopsie!</h1>
|
||||||
|
<img src="https://cdn.nhcarrigan.com/new-avatars/lynira.png" width="250" alt="Lynira" />
|
||||||
|
<section>
|
||||||
|
<p>It looks like the user who created this link has too many short URLs. They will need to delete some before this link works.</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Need Help?</h2>
|
||||||
|
<p>
|
||||||
|
<a href="https://git.nhcarrigan.com/nhcarrigan/lynira">
|
||||||
|
<i class="fa-solid fa-code"></i> Source Code
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="https://docs.nhcarrigan.com/">
|
||||||
|
<i class="fa-solid fa-book"></i> Documentation
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="https://chat.nhcarrigan.com">
|
||||||
|
<i class="fa-solid fa-circle-info"></i> Support
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
43
src/server/html/unsub.ts
Normal file
43
src/server/html/unsub.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const unsub = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Lynira</title>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="description" content="Link shortener service managed via a Discord bot." />
|
||||||
|
<script src="https://cdn.nhcarrigan.com/headers/index.js" async defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Oopsie!</h1>
|
||||||
|
<img src="https://cdn.nhcarrigan.com/new-avatars/lynira.png" width="250" alt="Lynira" />
|
||||||
|
<section>
|
||||||
|
<p>It looks like the user who created this link is no longer subscribed to our service! Please let them know that they will need to resubscribe before this URL works!</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Need Help?</h2>
|
||||||
|
<p>
|
||||||
|
<a href="https://git.nhcarrigan.com/nhcarrigan/lynira">
|
||||||
|
<i class="fa-solid fa-code"></i> Source Code
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="https://docs.nhcarrigan.com/">
|
||||||
|
<i class="fa-solid fa-book"></i> Documentation
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="https://chat.nhcarrigan.com">
|
||||||
|
<i class="fa-solid fa-circle-info"></i> Support
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
125
src/server/serve.ts
Normal file
125
src/server/serve.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fastify from "fastify";
|
||||||
|
import { getEntitlement } from "../modules/getEntitlement.js";
|
||||||
|
import { getSkuLimit } from "../modules/getSkuLimit.js";
|
||||||
|
import { logger } from "../utils/logger.js";
|
||||||
|
import { error } from "./html/error.js";
|
||||||
|
import { fourOhFour } from "./html/fourOhFour.js";
|
||||||
|
import { home } from "./html/home.js";
|
||||||
|
import { overlimit } from "./html/overlimit.js";
|
||||||
|
import { unsub } from "./html/unsub.js";
|
||||||
|
import type { Lynira } from "../interfaces/lynira.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts up a web server for health monitoring.
|
||||||
|
* @param lynira - Lynira's instance containing Discord client and database connection.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line max-lines-per-function -- Big function due to multiple routes.
|
||||||
|
export const instantiateServer = (lynira: Lynira): void => {
|
||||||
|
try {
|
||||||
|
const server = fastify({
|
||||||
|
logger: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
server.get("/", (_request, response) => {
|
||||||
|
response.header("Content-Type", "text/html");
|
||||||
|
response.send(home);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.get("/error", (_request, response) => {
|
||||||
|
response.header("Content-Type", "text/html");
|
||||||
|
response.send(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.get("/unsub", (_request, response) => {
|
||||||
|
response.header("Content-Type", "text/html");
|
||||||
|
response.send(unsub);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.get("/overlimit", (_request, response) => {
|
||||||
|
response.header("Content-Type", "text/html");
|
||||||
|
response.send(overlimit);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.get("/404", (_request, response) => {
|
||||||
|
response.status(404);
|
||||||
|
response.header("Content-Type", "text/html");
|
||||||
|
response.send(fourOhFour);
|
||||||
|
});
|
||||||
|
|
||||||
|
// WILDCARD: anything static must come before this route.
|
||||||
|
// eslint-disable-next-line max-statements -- Big function due to multiple routes.
|
||||||
|
server.get("*", async(request, response) => {
|
||||||
|
try {
|
||||||
|
const slug = request.url.replace(/^\//, "");
|
||||||
|
|
||||||
|
const exists = await lynira.db.links.findUnique({
|
||||||
|
where: {
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (exists === null) {
|
||||||
|
void logger.log("debug", `Link with slug "${slug}" does not exist.`);
|
||||||
|
return await response.redirect("/404");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exists.deleted) {
|
||||||
|
void logger.log("debug", `Link with slug "${slug}" has been deleted.`);
|
||||||
|
return await response.redirect("/404");
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await getEntitlement(lynira, exists.userId);
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
void logger.log("info", `User ${exists.userId} is not subscribed to Lynira, slug ${slug} will not work.`);
|
||||||
|
return await response.redirect("/unsub");
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = getSkuLimit(subscription.skuId);
|
||||||
|
|
||||||
|
const count = await lynira.db.links.count({
|
||||||
|
where: {
|
||||||
|
userId: exists.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (count >= limit) {
|
||||||
|
await logger.log("info", `User ${exists.userId} is on SKU ${subscription.skuId} and has reached ${count.toString()} / ${limit.toString()} links.`);
|
||||||
|
return await response.redirect("/overlimit");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.redirect(exists.url);
|
||||||
|
} catch (actualError) {
|
||||||
|
if (actualError instanceof Error) {
|
||||||
|
void logger.error("server wildcard route", actualError);
|
||||||
|
return await response.redirect("/error");
|
||||||
|
}
|
||||||
|
void logger.error(
|
||||||
|
"server wildcard route",
|
||||||
|
new Error(String(actualError)),
|
||||||
|
);
|
||||||
|
return await response.redirect("/error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen({ port: 5033 }, (actualError) => {
|
||||||
|
if (actualError) {
|
||||||
|
void logger.error("instantiate server", actualError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void logger.log("debug", "Server listening on port 5033.");
|
||||||
|
});
|
||||||
|
} catch (actualError) {
|
||||||
|
if (actualError instanceof Error) {
|
||||||
|
void logger.error("instantiate server", actualError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void logger.error("instantiate server", new Error(String(actualError)));
|
||||||
|
}
|
||||||
|
};
|
||||||
29
src/utils/errorHandler.ts
Normal file
29
src/utils/errorHandler.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { logger } from "./logger.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a UUID for an error, sends the error to the logger,
|
||||||
|
* and returns the UUID to be shared with the user.
|
||||||
|
* @param error - The error to log.
|
||||||
|
* @param context - The context in which the error occurred.
|
||||||
|
* @returns A UUID string assigned to the error.
|
||||||
|
*/
|
||||||
|
export const errorHandler = async(
|
||||||
|
error: unknown,
|
||||||
|
context: string,
|
||||||
|
): Promise<string> => {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
await logger.error(
|
||||||
|
`${context} - Error ID: ${id}`,
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error)),
|
||||||
|
);
|
||||||
|
return id;
|
||||||
|
};
|
||||||
12
src/utils/logger.ts
Normal file
12
src/utils/logger.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* @copyright nhcarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Logger } from "@nhcarrigan/logger";
|
||||||
|
|
||||||
|
export const logger = new Logger(
|
||||||
|
"Lynira",
|
||||||
|
process.env.LOG_TOKEN ?? "",
|
||||||
|
);
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "@nhcarrigan/typescript-config",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./prod",
|
||||||
|
"rootDir": "./src",
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user