Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: URL shortener #607

Merged
merged 5 commits into from
Apr 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ _If you fork/deploy your own instance it would mean a lot if you were to keep ei
- 📝 Snippets/Gists creation with direct links to share
- 🗃️ File management and file tagging
- 🙋 User management and quotas
- 🔗 Built-in URL shortener
- ✉️ Public or Private mode (with invite support)
- ⬆️ ShareX support out-of-the-box to upload screenshots/screenrecordings from your desktop
- 📱 iOS shortcut to upload files through the share menu
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
Warnings:

- Added the required column `generatedLinksLength` to the `settings` table without a default value. This is not possible if the table is not empty.

*/
-- CreateTable
CREATE TABLE "shortenedLinks" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"uuid" TEXT NOT NULL,
"userId" INTEGER,
"identifier" TEXT NOT NULL,
"destination" TEXT NOT NULL,
"views" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_settings" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"rateLimitWindow" INTEGER NOT NULL,
"rateLimitMax" INTEGER NOT NULL,
"secret" TEXT NOT NULL,
"serviceName" TEXT NOT NULL,
"chunkSize" TEXT NOT NULL,
"chunkedUploadsTimeout" INTEGER NOT NULL,
"maxSize" TEXT NOT NULL,
"generateZips" BOOLEAN NOT NULL,
"generateOriginalFileNameWithIdentifier" BOOLEAN NOT NULL DEFAULT false,
"generatedFilenameLength" INTEGER NOT NULL,
"generatedAlbumLength" INTEGER NOT NULL,
"generatedLinksLength" INTEGER NOT NULL,
"blockedExtensions" TEXT NOT NULL,
"blockNoExtension" BOOLEAN NOT NULL,
"publicMode" BOOLEAN NOT NULL,
"userAccounts" BOOLEAN NOT NULL,
"disableStatisticsCron" BOOLEAN NOT NULL,
"disableUpdateCheck" BOOLEAN NOT NULL DEFAULT false,
"backgroundImageURL" TEXT NOT NULL,
"logoURL" TEXT NOT NULL,
"metaDescription" TEXT NOT NULL,
"metaKeywords" TEXT NOT NULL,
"metaTwitterHandle" TEXT NOT NULL,
"metaDomain" TEXT NOT NULL DEFAULT '',
"serveUploadsFrom" TEXT NOT NULL DEFAULT '',
"enableMixedCaseFilenames" BOOLEAN NOT NULL DEFAULT true,
"usersStorageQuota" TEXT NOT NULL DEFAULT '0',
"useNetworkStorage" BOOLEAN NOT NULL DEFAULT false,
"useMinimalHomepage" BOOLEAN NOT NULL DEFAULT false,
"S3Region" TEXT NOT NULL DEFAULT '',
"S3Bucket" TEXT NOT NULL DEFAULT '',
"S3AccessKey" TEXT NOT NULL DEFAULT '',
"S3SecretKey" TEXT NOT NULL DEFAULT '',
"S3Endpoint" TEXT NOT NULL DEFAULT '',
"S3PublicUrl" TEXT NOT NULL DEFAULT ''
);
INSERT INTO "new_settings" ("S3AccessKey", "S3Bucket", "S3Endpoint", "S3PublicUrl", "S3Region", "S3SecretKey", "backgroundImageURL", "blockNoExtension", "blockedExtensions", "chunkSize", "chunkedUploadsTimeout", "disableStatisticsCron", "disableUpdateCheck", "enableMixedCaseFilenames", "generateOriginalFileNameWithIdentifier", "generateZips", "generatedAlbumLength", "generatedFilenameLength", "id", "logoURL", "maxSize", "metaDescription", "metaDomain", "metaKeywords", "metaTwitterHandle", "publicMode", "rateLimitMax", "rateLimitWindow", "secret", "serveUploadsFrom", "serviceName", "useMinimalHomepage", "useNetworkStorage", "userAccounts", "usersStorageQuota") SELECT "S3AccessKey", "S3Bucket", "S3Endpoint", "S3PublicUrl", "S3Region", "S3SecretKey", "backgroundImageURL", "blockNoExtension", "blockedExtensions", "chunkSize", "chunkedUploadsTimeout", "disableStatisticsCron", "disableUpdateCheck", "enableMixedCaseFilenames", "generateOriginalFileNameWithIdentifier", "generateZips", "generatedAlbumLength", "generatedFilenameLength", "id", "logoURL", "maxSize", "metaDescription", "metaDomain", "metaKeywords", "metaTwitterHandle", "publicMode", "rateLimitMax", "rateLimitWindow", "secret", "serveUploadsFrom", "serviceName", "useMinimalHomepage", "useNetworkStorage", "userAccounts", "usersStorageQuota" FROM "settings";
DROP TABLE "settings";
ALTER TABLE "new_settings" RENAME TO "settings";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

-- CreateIndex
CREATE UNIQUE INDEX "shortenedLinks_uuid_key" ON "shortenedLinks"("uuid");

-- CreateIndex
CREATE UNIQUE INDEX "shortenedLinks_identifier_key" ON "shortenedLinks"("identifier");
11 changes: 11 additions & 0 deletions packages/backend/src/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,16 @@ model snippets {
editedAt DateTime?
}

model shortenedLinks {
id Int @id @default(autoincrement())
uuid String @unique
userId Int?
identifier String @unique
destination String
views Int @default(0)
createdAt DateTime @default(now())
}

model albums {
id Int @id @default(autoincrement())
uuid String @unique
Expand Down Expand Up @@ -120,6 +130,7 @@ model settings {
generateOriginalFileNameWithIdentifier Boolean @default(false)
generatedFilenameLength Int
generatedAlbumLength Int
generatedLinksLength Int
blockedExtensions String
blockNoExtension Boolean
publicMode Boolean
Expand Down
99 changes: 99 additions & 0 deletions packages/backend/src/routes/links/CreateLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import slugify from '@sindresorhus/slugify';
import type { FastifyReply } from 'fastify';
import moment from 'moment';
import { v4 as uuidv4 } from 'uuid';
import { z } from 'zod';
import prisma from '@/structures/database.js';
import type { RequestWithUser } from '@/structures/interfaces.js';
import { http4xxErrorSchema } from '@/structures/schemas/HTTP4xxError.js';
import { http5xxErrorSchema } from '@/structures/schemas/HTTP5xxError.js';
import { responseMessageSchema } from '@/structures/schemas/ResponseMessage.js';
import { constructShortLink, getUniqueShortLinkIdentifier } from '@/utils/Link.js';

export const schema = {
summary: 'Create link',
description: 'Create a new shortened link',
tags: ['Links'],
body: z.object({
url: z.string().optional().describe('The destination url.'),
vanity: z.string().optional().describe('The vanity url to use.')
}),
response: {
200: z.object({
message: responseMessageSchema,
link: z.object({
uuid: z.string().describe('The uuid of the link.'),
identifier: z.string().describe('The identifier of the link.'),
link: z.string().describe('The shortened link.')
})
}),
'4xx': http4xxErrorSchema,
'5xx': http5xxErrorSchema
}
};

export const options = {
url: '/link/create',
method: 'post',
middlewares: [
{
name: 'apiKey'
},
{
name: 'auth'
}
]
};

export const run = async (req: RequestWithUser, res: FastifyReply) => {
const { url, vanity } = req.body as { url: string; vanity?: string | undefined };

console.log(url, vanity);
const now = moment.utc().toDate();

let linkToUse;

if (vanity) {
const sluggifiedVanity = slugify(vanity, { separator: '_', decamelize: false, lowercase: false });
const existingLink = await prisma.shortenedLinks.findFirst({
where: {
identifier: sluggifiedVanity
}
});

// If the vanity url doesn't exist, we'll use it
if (!existingLink) {
linkToUse = sluggifiedVanity;
}
}

// If linkToUse is still undefined, we'll generate a unique identifier
if (!linkToUse) {
const uniqueIdentifier = await getUniqueShortLinkIdentifier();
if (!uniqueIdentifier) {
void res.internalServerError('Couldnt allocate identifier for link');
return;
}

linkToUse = uniqueIdentifier;
}

const link = await prisma.shortenedLinks.create({
data: {
destination: url,
identifier: linkToUse,
userId: req.user.id,
uuid: uuidv4(),
createdAt: now
}
});

return res.send({
message: 'Successfully created link',
link: {
uuid: link.uuid,
identifier: link.identifier,
link: constructShortLink(req, link.identifier)
}
});
};
60 changes: 60 additions & 0 deletions packages/backend/src/routes/links/DeleteLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { FastifyReply } from 'fastify';
import { z } from 'zod';
import prisma from '@/structures/database.js';
import type { RequestWithUser } from '@/structures/interfaces.js';
import { http4xxErrorSchema } from '@/structures/schemas/HTTP4xxError.js';
import { http5xxErrorSchema } from '@/structures/schemas/HTTP5xxError.js';
import { responseMessageSchema } from '@/structures/schemas/ResponseMessage.js';

export const schema = {
summary: 'Delete link',
description: 'Deletes a link',
tags: ['Links'],
params: z
.object({
identifier: z.string().describe('The identifier of the link.')
})
.required(),
response: {
200: z.object({
message: responseMessageSchema
}),
'4xx': http4xxErrorSchema,
'5xx': http5xxErrorSchema
}
};

export const options = {
url: '/link/:identifier',
method: 'delete',
middlewares: ['apiKey', 'auth']
};

export const run = async (req: RequestWithUser, res: FastifyReply) => {
const { identifier } = req.params as { identifier: string };

const link = await prisma.shortenedLinks.findFirst({
where: {
userId: req.user.id,
identifier
},
select: {
identifier: true
}
});

if (!link) {
void res.badRequest("The shortened link doesn't exist or doesn't belong to the user");
return;
}

await prisma.shortenedLinks.delete({
where: {
identifier
}
});

return res.send({
message: 'Successfully deleted shortened link'
});
};
55 changes: 55 additions & 0 deletions packages/backend/src/routes/links/GetLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { FastifyReply } from 'fastify';
import prisma from '@/structures/database.js';
import type { RequestWithUser } from '@/structures/interfaces.js';
import { http4xxErrorSchema } from '@/structures/schemas/HTTP4xxError.js';
import { http5xxErrorSchema } from '@/structures/schemas/HTTP5xxError.js';

export const schema = {
summary: 'Get link',
description: 'Get a link and redirect the response',
tags: ['Links'],
response: {
'4xx': http4xxErrorSchema,
'5xx': http5xxErrorSchema
}
};

export const options = {
url: '/link/:identifier',
method: 'get'
};

export const run = async (req: RequestWithUser, res: FastifyReply) => {
const { identifier } = req.params as { identifier: string };

const link = await prisma.shortenedLinks.findFirst({
where: {
identifier
},
select: {
destination: true
}
});

if (!link) {
void res.notFound('The link could not be found');
return;
}

await prisma.shortenedLinks.update({
where: {
identifier
},
data: {
views: {
increment: 1
}
}
});

if (!link.destination.startsWith('http')) {
link.destination = `http://${link.destination}`;
}

return res.redirect(link.destination);
};