feat: build out initial prototype (#1)
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:
2025-08-11 19:40:09 -07:00
committed by Naomi Carrigan
parent cdaeff99d1
commit cd56334483
33 changed files with 6135 additions and 0 deletions

38
.gitea/workflows/ci.yml Normal file
View 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
View File

@@ -0,0 +1,2 @@
prod
node_modules

6
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"eslint.validate": ["typescript"]
}

52
commandJson.js Normal file
View 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
View File

@@ -0,0 +1,5 @@
import NaomisConfig from '@nhcarrigan/eslint-config';
export default [
...NaomisConfig,
]

31
package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

22
prisma/schema.prisma Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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,
};

View 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
View 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
View 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
View 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
View 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",
}

View 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;
};

View 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;
};

View 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;
};

View 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);
};

View 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
View 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>`;

View 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
View 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>`;

View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
{
"extends": "@nhcarrigan/typescript-config",
"compilerOptions": {
"outDir": "./prod",
"rootDir": "./src",
}
}