Skip to content

Commit

Permalink
feat: URL shortener (#607)
Browse files Browse the repository at this point in the history
  • Loading branch information
Pitu committed Apr 17, 2024
1 parent 6a4f5c5 commit e741268
Show file tree
Hide file tree
Showing 20 changed files with 909 additions and 2 deletions.
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);
};

0 comments on commit e741268

Please sign in to comment.