update global (texte + logo)
This commit is contained in:
@ -0,0 +1,129 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import getPermission from "@/lib/api/getPermission";
|
||||
import { Collection, UsersAndCollections } from "@prisma/client";
|
||||
import removeFolder from "@/lib/api/storage/removeFolder";
|
||||
|
||||
export default async function deleteCollection(
|
||||
userId: number,
|
||||
collectionId: number
|
||||
) {
|
||||
if (!collectionId)
|
||||
return { response: "Please choose a valid collection.", status: 401 };
|
||||
|
||||
const collectionIsAccessible = await getPermission({
|
||||
userId,
|
||||
collectionId,
|
||||
});
|
||||
|
||||
const memberHasAccess = collectionIsAccessible?.members.some(
|
||||
(e: UsersAndCollections) => e.userId === userId
|
||||
);
|
||||
|
||||
if (collectionIsAccessible?.ownerId !== userId && memberHasAccess) {
|
||||
// Remove relation/Leave collection
|
||||
const deletedUsersAndCollectionsRelation =
|
||||
await prisma.usersAndCollections.delete({
|
||||
where: {
|
||||
userId_collectionId: {
|
||||
userId: userId,
|
||||
collectionId: collectionId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await removeFromOrders(userId, collectionId);
|
||||
|
||||
return { response: deletedUsersAndCollectionsRelation, status: 200 };
|
||||
} else if (collectionIsAccessible?.ownerId !== userId) {
|
||||
return { response: "Collection is not accessible.", status: 401 };
|
||||
}
|
||||
|
||||
const deletedCollection = await prisma.$transaction(async () => {
|
||||
await deleteSubCollections(collectionId);
|
||||
|
||||
await prisma.usersAndCollections.deleteMany({
|
||||
where: {
|
||||
collection: {
|
||||
id: collectionId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.link.deleteMany({
|
||||
where: {
|
||||
collection: {
|
||||
id: collectionId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await removeFolder({ filePath: `archives/${collectionId}` });
|
||||
|
||||
await removeFromOrders(userId, collectionId);
|
||||
|
||||
return await prisma.collection.delete({
|
||||
where: {
|
||||
id: collectionId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return { response: deletedCollection, status: 200 };
|
||||
}
|
||||
|
||||
async function deleteSubCollections(collectionId: number) {
|
||||
const subCollections = await prisma.collection.findMany({
|
||||
where: { parentId: collectionId },
|
||||
});
|
||||
|
||||
for (const subCollection of subCollections) {
|
||||
await deleteSubCollections(subCollection.id);
|
||||
|
||||
await prisma.usersAndCollections.deleteMany({
|
||||
where: {
|
||||
collection: {
|
||||
id: subCollection.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.link.deleteMany({
|
||||
where: {
|
||||
collection: {
|
||||
id: subCollection.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.collection.delete({
|
||||
where: { id: subCollection.id },
|
||||
});
|
||||
|
||||
await removeFolder({ filePath: `archives/${subCollection.id}` });
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFromOrders(userId: number, collectionId: number) {
|
||||
const userCollectionOrder = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
collectionOrder: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (userCollectionOrder)
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
collectionOrder: {
|
||||
set: userCollectionOrder.collectionOrder.filter(
|
||||
(e: number) => e !== collectionId
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
|
||||
export default async function getCollectionById(
|
||||
userId: number,
|
||||
collectionId: number
|
||||
) {
|
||||
const collections = await prisma.collection.findFirst({
|
||||
where: {
|
||||
id: collectionId,
|
||||
OR: [
|
||||
{ ownerId: userId },
|
||||
{ members: { some: { user: { id: userId } } } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { links: true },
|
||||
},
|
||||
members: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
username: true,
|
||||
name: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { response: collections, status: 200 };
|
||||
}
|
@ -0,0 +1,107 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
|
||||
import getPermission from "@/lib/api/getPermission";
|
||||
|
||||
export default async function updateCollection(
|
||||
userId: number,
|
||||
collectionId: number,
|
||||
data: CollectionIncludingMembersAndLinkCount
|
||||
) {
|
||||
if (!collectionId)
|
||||
return { response: "Please choose a valid collection.", status: 401 };
|
||||
|
||||
const collectionIsAccessible = await getPermission({
|
||||
userId,
|
||||
collectionId,
|
||||
});
|
||||
|
||||
if (!(collectionIsAccessible?.ownerId === userId))
|
||||
return { response: "Collection is not accessible.", status: 401 };
|
||||
|
||||
console.log(data);
|
||||
|
||||
if (data.parentId) {
|
||||
if (data.parentId !== ("root" as any)) {
|
||||
const findParentCollection = await prisma.collection.findUnique({
|
||||
where: {
|
||||
id: data.parentId,
|
||||
},
|
||||
select: {
|
||||
ownerId: true,
|
||||
parentId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
findParentCollection?.ownerId !== userId ||
|
||||
typeof data.parentId !== "number" ||
|
||||
findParentCollection?.parentId === data.parentId
|
||||
)
|
||||
return {
|
||||
response: "You are not authorized to create a sub-collection here.",
|
||||
status: 403,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const updatedCollection = await prisma.$transaction(async () => {
|
||||
await prisma.usersAndCollections.deleteMany({
|
||||
where: {
|
||||
collection: {
|
||||
id: collectionId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return await prisma.collection.update({
|
||||
where: {
|
||||
id: collectionId,
|
||||
},
|
||||
data: {
|
||||
name: data.name.trim(),
|
||||
description: data.description,
|
||||
color: data.color,
|
||||
isPublic: data.isPublic,
|
||||
parent:
|
||||
data.parentId && data.parentId !== ("root" as any)
|
||||
? {
|
||||
connect: {
|
||||
id: data.parentId,
|
||||
},
|
||||
}
|
||||
: data.parentId === ("root" as any)
|
||||
? {
|
||||
disconnect: true,
|
||||
}
|
||||
: undefined,
|
||||
members: {
|
||||
create: data.members.map((e) => ({
|
||||
user: { connect: { id: e.user.id || e.userId } },
|
||||
canCreate: e.canCreate,
|
||||
canUpdate: e.canUpdate,
|
||||
canDelete: e.canDelete,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { links: true },
|
||||
},
|
||||
members: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
image: true,
|
||||
username: true,
|
||||
name: true,
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return { response: updatedCollection, status: 200 };
|
||||
}
|
36
Linkwarden/lib/api/controllers/collections/getCollections.ts
Normal file
36
Linkwarden/lib/api/controllers/collections/getCollections.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
|
||||
export default async function getCollection(userId: number) {
|
||||
const collections = await prisma.collection.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ ownerId: userId },
|
||||
{ members: { some: { user: { id: userId } } } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { links: true },
|
||||
},
|
||||
parent: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
members: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
username: true,
|
||||
name: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { response: collections, status: 200 };
|
||||
}
|
84
Linkwarden/lib/api/controllers/collections/postCollection.ts
Normal file
84
Linkwarden/lib/api/controllers/collections/postCollection.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
|
||||
import createFolder from "@/lib/api/storage/createFolder";
|
||||
|
||||
export default async function postCollection(
|
||||
collection: CollectionIncludingMembersAndLinkCount,
|
||||
userId: number
|
||||
) {
|
||||
if (!collection || collection.name.trim() === "")
|
||||
return {
|
||||
response: "Please enter a valid collection.",
|
||||
status: 400,
|
||||
};
|
||||
|
||||
if (collection.parentId) {
|
||||
const findParentCollection = await prisma.collection.findUnique({
|
||||
where: {
|
||||
id: collection.parentId,
|
||||
},
|
||||
select: {
|
||||
ownerId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
findParentCollection?.ownerId !== userId ||
|
||||
typeof collection.parentId !== "number"
|
||||
)
|
||||
return {
|
||||
response: "You are not authorized to create a sub-collection here.",
|
||||
status: 403,
|
||||
};
|
||||
}
|
||||
|
||||
const newCollection = await prisma.collection.create({
|
||||
data: {
|
||||
owner: {
|
||||
connect: {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
name: collection.name.trim(),
|
||||
description: collection.description,
|
||||
color: collection.color,
|
||||
parent: collection.parentId
|
||||
? {
|
||||
connect: {
|
||||
id: collection.parentId,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { links: true },
|
||||
},
|
||||
members: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
username: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
collectionOrder: {
|
||||
push: newCollection.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
createFolder({ filePath: `archives/${newCollection.id}` });
|
||||
|
||||
return { response: newCollection, status: 200 };
|
||||
}
|
78
Linkwarden/lib/api/controllers/dashboard/getDashboardData.ts
Normal file
78
Linkwarden/lib/api/controllers/dashboard/getDashboardData.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import { LinkRequestQuery, Sort } from "@/types/global";
|
||||
|
||||
export default async function getDashboardData(
|
||||
userId: number,
|
||||
query: LinkRequestQuery
|
||||
) {
|
||||
let order: any;
|
||||
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
|
||||
else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
|
||||
else if (query.sort === Sort.NameAZ) order = { name: "asc" };
|
||||
else if (query.sort === Sort.NameZA) order = { name: "desc" };
|
||||
else if (query.sort === Sort.DescriptionAZ) order = { description: "asc" };
|
||||
else if (query.sort === Sort.DescriptionZA) order = { description: "desc" };
|
||||
|
||||
const pinnedLinks = await prisma.link.findMany({
|
||||
take: 8,
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
collection: {
|
||||
OR: [
|
||||
{ ownerId: userId },
|
||||
{
|
||||
members: {
|
||||
some: { userId },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
pinnedBy: { some: { id: userId } },
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
tags: true,
|
||||
collection: true,
|
||||
pinnedBy: {
|
||||
where: { id: userId },
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
orderBy: order || { id: "desc" },
|
||||
});
|
||||
|
||||
const recentlyAddedLinks = await prisma.link.findMany({
|
||||
take: 8,
|
||||
where: {
|
||||
collection: {
|
||||
OR: [
|
||||
{ ownerId: userId },
|
||||
{
|
||||
members: {
|
||||
some: { userId },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
include: {
|
||||
tags: true,
|
||||
collection: true,
|
||||
pinnedBy: {
|
||||
where: { id: userId },
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
orderBy: order || { id: "desc" },
|
||||
});
|
||||
|
||||
const links = [...recentlyAddedLinks, ...pinnedLinks].sort(
|
||||
(a, b) => (new Date(b.id) as any) - (new Date(a.id) as any)
|
||||
);
|
||||
|
||||
return { response: links, status: 200 };
|
||||
}
|
58
Linkwarden/lib/api/controllers/links/bulk/deleteLinksById.ts
Normal file
58
Linkwarden/lib/api/controllers/links/bulk/deleteLinksById.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import { UsersAndCollections } from "@prisma/client";
|
||||
import getPermission from "@/lib/api/getPermission";
|
||||
import removeFile from "@/lib/api/storage/removeFile";
|
||||
|
||||
export default async function deleteLinksById(
|
||||
userId: number,
|
||||
linkIds: number[]
|
||||
) {
|
||||
if (!linkIds || linkIds.length === 0) {
|
||||
return { response: "Please choose valid links.", status: 401 };
|
||||
}
|
||||
|
||||
const collectionIsAccessibleArray = [];
|
||||
|
||||
// Check if the user has access to the collection of each link
|
||||
// if any of the links are not accessible, return an error
|
||||
// if all links are accessible, continue with the deletion
|
||||
// and add the collection to the collectionIsAccessibleArray
|
||||
for (const linkId of linkIds) {
|
||||
const collectionIsAccessible = await getPermission({ userId, linkId });
|
||||
|
||||
const memberHasAccess = collectionIsAccessible?.members.some(
|
||||
(e: UsersAndCollections) => e.userId === userId && e.canDelete
|
||||
);
|
||||
|
||||
if (!(collectionIsAccessible?.ownerId === userId || memberHasAccess)) {
|
||||
return { response: "Collection is not accessible.", status: 401 };
|
||||
}
|
||||
|
||||
collectionIsAccessibleArray.push(collectionIsAccessible);
|
||||
}
|
||||
|
||||
const deletedLinks = await prisma.link.deleteMany({
|
||||
where: {
|
||||
id: { in: linkIds },
|
||||
},
|
||||
});
|
||||
|
||||
// Loop through each link and delete the associated files
|
||||
// if the user has access to the collection
|
||||
for (let i = 0; i < linkIds.length; i++) {
|
||||
const linkId = linkIds[i];
|
||||
const collectionIsAccessible = collectionIsAccessibleArray[i];
|
||||
|
||||
removeFile({
|
||||
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.pdf`,
|
||||
});
|
||||
removeFile({
|
||||
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.png`,
|
||||
});
|
||||
removeFile({
|
||||
filePath: `archives/${collectionIsAccessible?.id}/${linkId}_readability.json`,
|
||||
});
|
||||
}
|
||||
|
||||
return { response: deletedLinks, status: 200 };
|
||||
}
|
50
Linkwarden/lib/api/controllers/links/bulk/updateLinks.ts
Normal file
50
Linkwarden/lib/api/controllers/links/bulk/updateLinks.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||
import updateLinkById from "../linkId/updateLinkById";
|
||||
|
||||
export default async function updateLinks(
|
||||
userId: number,
|
||||
links: LinkIncludingShortenedCollectionAndTags[],
|
||||
removePreviousTags: boolean,
|
||||
newData: Pick<
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
"tags" | "collectionId"
|
||||
>
|
||||
) {
|
||||
let allUpdatesSuccessful = true;
|
||||
|
||||
// Have to use a loop here rather than updateMany, see the following:
|
||||
// https://github.com/prisma/prisma/issues/3143
|
||||
for (const link of links) {
|
||||
let updatedTags = [...link.tags, ...(newData.tags ?? [])];
|
||||
|
||||
if (removePreviousTags) {
|
||||
// If removePreviousTags is true, replace the existing tags with new tags
|
||||
updatedTags = [...(newData.tags ?? [])];
|
||||
}
|
||||
|
||||
const updatedData: LinkIncludingShortenedCollectionAndTags = {
|
||||
...link,
|
||||
tags: updatedTags,
|
||||
collection: {
|
||||
...link.collection,
|
||||
id: newData.collectionId ?? link.collection.id,
|
||||
},
|
||||
};
|
||||
|
||||
const updatedLink = await updateLinkById(
|
||||
userId,
|
||||
link.id as number,
|
||||
updatedData
|
||||
);
|
||||
|
||||
if (updatedLink.status !== 200) {
|
||||
allUpdatesSuccessful = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (allUpdatesSuccessful) {
|
||||
return { response: "All links updated successfully", status: 200 };
|
||||
} else {
|
||||
return { response: "Some links failed to update", status: 400 };
|
||||
}
|
||||
}
|
152
Linkwarden/lib/api/controllers/links/getLinks.ts
Normal file
152
Linkwarden/lib/api/controllers/links/getLinks.ts
Normal file
@ -0,0 +1,152 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import { LinkRequestQuery, Sort } from "@/types/global";
|
||||
|
||||
export default async function getLink(userId: number, query: LinkRequestQuery) {
|
||||
const POSTGRES_IS_ENABLED = process.env.DATABASE_URL.startsWith("postgresql");
|
||||
|
||||
let order: any;
|
||||
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
|
||||
else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
|
||||
else if (query.sort === Sort.NameAZ) order = { name: "asc" };
|
||||
else if (query.sort === Sort.NameZA) order = { name: "desc" };
|
||||
else if (query.sort === Sort.DescriptionAZ) order = { description: "asc" };
|
||||
else if (query.sort === Sort.DescriptionZA) order = { description: "desc" };
|
||||
|
||||
const searchConditions = [];
|
||||
|
||||
if (query.searchQueryString) {
|
||||
if (query.searchByName) {
|
||||
searchConditions.push({
|
||||
name: {
|
||||
contains: query.searchQueryString,
|
||||
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (query.searchByUrl) {
|
||||
searchConditions.push({
|
||||
url: {
|
||||
contains: query.searchQueryString,
|
||||
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (query.searchByDescription) {
|
||||
searchConditions.push({
|
||||
description: {
|
||||
contains: query.searchQueryString,
|
||||
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (query.searchByTextContent) {
|
||||
searchConditions.push({
|
||||
textContent: {
|
||||
contains: query.searchQueryString,
|
||||
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (query.searchByTags) {
|
||||
searchConditions.push({
|
||||
tags: {
|
||||
some: {
|
||||
name: {
|
||||
contains: query.searchQueryString,
|
||||
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
|
||||
},
|
||||
OR: [
|
||||
{ ownerId: userId },
|
||||
{
|
||||
links: {
|
||||
some: {
|
||||
collection: {
|
||||
members: {
|
||||
some: { userId },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const tagCondition = [];
|
||||
|
||||
if (query.tagId) {
|
||||
tagCondition.push({
|
||||
tags: {
|
||||
some: {
|
||||
id: query.tagId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const collectionCondition = [];
|
||||
|
||||
if (query.collectionId) {
|
||||
collectionCondition.push({
|
||||
collection: {
|
||||
id: query.collectionId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const links = await prisma.link.findMany({
|
||||
take: Number(process.env.PAGINATION_TAKE_COUNT) || 20,
|
||||
skip: query.cursor ? 1 : undefined,
|
||||
cursor: query.cursor ? { id: query.cursor } : undefined,
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
collection: {
|
||||
OR: [
|
||||
{ ownerId: userId },
|
||||
{
|
||||
members: {
|
||||
some: { userId },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
...collectionCondition,
|
||||
{
|
||||
OR: [
|
||||
...tagCondition,
|
||||
{
|
||||
[query.searchQueryString ? "OR" : "AND"]: [
|
||||
{
|
||||
pinnedBy: query.pinnedOnly
|
||||
? { some: { id: userId } }
|
||||
: undefined,
|
||||
},
|
||||
...searchConditions,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
tags: true,
|
||||
collection: true,
|
||||
pinnedBy: {
|
||||
where: { id: userId },
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
orderBy: order || { id: "desc" },
|
||||
});
|
||||
|
||||
return { response: links, status: 200 };
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import { Link, UsersAndCollections } from "@prisma/client";
|
||||
import getPermission from "@/lib/api/getPermission";
|
||||
import removeFile from "@/lib/api/storage/removeFile";
|
||||
|
||||
export default async function deleteLink(userId: number, linkId: number) {
|
||||
if (!linkId) return { response: "Please choose a valid link.", status: 401 };
|
||||
|
||||
const collectionIsAccessible = await getPermission({ userId, linkId });
|
||||
|
||||
const memberHasAccess = collectionIsAccessible?.members.some(
|
||||
(e: UsersAndCollections) => e.userId === userId && e.canDelete
|
||||
);
|
||||
|
||||
if (!(collectionIsAccessible?.ownerId === userId || memberHasAccess))
|
||||
return { response: "Collection is not accessible.", status: 401 };
|
||||
|
||||
const deleteLink: Link = await prisma.link.delete({
|
||||
where: {
|
||||
id: linkId,
|
||||
},
|
||||
});
|
||||
|
||||
removeFile({
|
||||
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.pdf`,
|
||||
});
|
||||
removeFile({
|
||||
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.png`,
|
||||
});
|
||||
removeFile({
|
||||
filePath: `archives/${collectionIsAccessible?.id}/${linkId}_readability.json`,
|
||||
});
|
||||
|
||||
return { response: deleteLink, status: 200 };
|
||||
}
|
44
Linkwarden/lib/api/controllers/links/linkId/getLinkById.ts
Normal file
44
Linkwarden/lib/api/controllers/links/linkId/getLinkById.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import { Collection, UsersAndCollections } from "@prisma/client";
|
||||
import getPermission from "@/lib/api/getPermission";
|
||||
|
||||
export default async function getLinkById(userId: number, linkId: number) {
|
||||
if (!linkId)
|
||||
return {
|
||||
response: "Please choose a valid link.",
|
||||
status: 401,
|
||||
};
|
||||
|
||||
const collectionIsAccessible = await getPermission({ userId, linkId });
|
||||
|
||||
const memberHasAccess = collectionIsAccessible?.members.some(
|
||||
(e: UsersAndCollections) => e.userId === userId
|
||||
);
|
||||
|
||||
const isCollectionOwner = collectionIsAccessible?.ownerId === userId;
|
||||
|
||||
if (collectionIsAccessible?.ownerId !== userId && !memberHasAccess)
|
||||
return {
|
||||
response: "Collection is not accessible.",
|
||||
status: 401,
|
||||
};
|
||||
else {
|
||||
const link = await prisma.link.findUnique({
|
||||
where: {
|
||||
id: linkId,
|
||||
},
|
||||
include: {
|
||||
tags: true,
|
||||
collection: true,
|
||||
pinnedBy: isCollectionOwner
|
||||
? {
|
||||
where: { id: userId },
|
||||
select: { id: true },
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return { response: link, status: 200 };
|
||||
}
|
||||
}
|
167
Linkwarden/lib/api/controllers/links/linkId/updateLinkById.ts
Normal file
167
Linkwarden/lib/api/controllers/links/linkId/updateLinkById.ts
Normal file
@ -0,0 +1,167 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||
import { UsersAndCollections } from "@prisma/client";
|
||||
import getPermission from "@/lib/api/getPermission";
|
||||
import moveFile from "@/lib/api/storage/moveFile";
|
||||
|
||||
export default async function updateLinkById(
|
||||
userId: number,
|
||||
linkId: number,
|
||||
data: LinkIncludingShortenedCollectionAndTags
|
||||
) {
|
||||
if (!data || !data.collection.id)
|
||||
return {
|
||||
response: "Please choose a valid link and collection.",
|
||||
status: 401,
|
||||
};
|
||||
|
||||
const collectionIsAccessible = await getPermission({ userId, linkId });
|
||||
|
||||
const isCollectionOwner =
|
||||
collectionIsAccessible?.ownerId === data.collection.ownerId &&
|
||||
data.collection.ownerId === userId;
|
||||
|
||||
const canPinPermission = collectionIsAccessible?.members.some(
|
||||
(e: UsersAndCollections) => e.userId === userId
|
||||
);
|
||||
|
||||
// If the user is able to create a link, they can pin it to their dashboard only.
|
||||
if (canPinPermission) {
|
||||
const updatedLink = await prisma.link.update({
|
||||
where: {
|
||||
id: linkId,
|
||||
},
|
||||
data: {
|
||||
pinnedBy:
|
||||
data?.pinnedBy && data.pinnedBy[0]
|
||||
? { connect: { id: userId } }
|
||||
: { disconnect: { id: userId } },
|
||||
},
|
||||
include: {
|
||||
collection: true,
|
||||
pinnedBy: isCollectionOwner
|
||||
? {
|
||||
where: { id: userId },
|
||||
select: { id: true },
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return { response: updatedLink, status: 200 };
|
||||
}
|
||||
|
||||
const targetCollectionIsAccessible = await getPermission({
|
||||
userId,
|
||||
collectionId: data.collection.id,
|
||||
});
|
||||
|
||||
const memberHasAccess = collectionIsAccessible?.members.some(
|
||||
(e: UsersAndCollections) => e.userId === userId && e.canUpdate
|
||||
);
|
||||
|
||||
const targetCollectionsAccessible =
|
||||
targetCollectionIsAccessible?.ownerId === userId;
|
||||
|
||||
const targetCollectionMatchesData = data.collection.id
|
||||
? data.collection.id === targetCollectionIsAccessible?.id
|
||||
: true && data.collection.name
|
||||
? data.collection.name === targetCollectionIsAccessible?.name
|
||||
: true && data.collection.ownerId
|
||||
? data.collection.ownerId === targetCollectionIsAccessible?.ownerId
|
||||
: true;
|
||||
|
||||
if (!targetCollectionsAccessible)
|
||||
return {
|
||||
response: "Target collection is not accessible.",
|
||||
status: 401,
|
||||
};
|
||||
else if (!targetCollectionMatchesData)
|
||||
return {
|
||||
response: "Target collection does not match the data.",
|
||||
status: 401,
|
||||
};
|
||||
|
||||
const unauthorizedSwitchCollection =
|
||||
!isCollectionOwner && collectionIsAccessible?.id !== data.collection.id;
|
||||
|
||||
// Makes sure collection members (non-owners) cannot move a link to/from a collection.
|
||||
if (unauthorizedSwitchCollection)
|
||||
return {
|
||||
response: "You can't move a link to/from a collection you don't own.",
|
||||
status: 401,
|
||||
};
|
||||
else if (collectionIsAccessible?.ownerId !== userId && !memberHasAccess)
|
||||
return {
|
||||
response: "Collection is not accessible.",
|
||||
status: 401,
|
||||
};
|
||||
else {
|
||||
const updatedLink = await prisma.link.update({
|
||||
where: {
|
||||
id: linkId,
|
||||
},
|
||||
data: {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
collection: {
|
||||
connect: {
|
||||
id: data.collection.id,
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
set: [],
|
||||
connectOrCreate: data.tags.map((tag) => ({
|
||||
where: {
|
||||
name_ownerId: {
|
||||
name: tag.name,
|
||||
ownerId: data.collection.ownerId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
name: tag.name,
|
||||
owner: {
|
||||
connect: {
|
||||
id: data.collection.ownerId,
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
pinnedBy:
|
||||
data?.pinnedBy && data.pinnedBy[0]
|
||||
? { connect: { id: userId } }
|
||||
: { disconnect: { id: userId } },
|
||||
},
|
||||
include: {
|
||||
tags: true,
|
||||
collection: true,
|
||||
pinnedBy: isCollectionOwner
|
||||
? {
|
||||
where: { id: userId },
|
||||
select: { id: true },
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (collectionIsAccessible?.id !== data.collection.id) {
|
||||
await moveFile(
|
||||
`archives/${collectionIsAccessible?.id}/${linkId}.pdf`,
|
||||
`archives/${data.collection.id}/${linkId}.pdf`
|
||||
);
|
||||
|
||||
await moveFile(
|
||||
`archives/${collectionIsAccessible?.id}/${linkId}.png`,
|
||||
`archives/${data.collection.id}/${linkId}.png`
|
||||
);
|
||||
|
||||
await moveFile(
|
||||
`archives/${collectionIsAccessible?.id}/${linkId}_readability.json`,
|
||||
`archives/${data.collection.id}/${linkId}_readability.json`
|
||||
);
|
||||
}
|
||||
|
||||
return { response: updatedLink, status: 200 };
|
||||
}
|
||||
}
|
209
Linkwarden/lib/api/controllers/links/postLink.ts
Normal file
209
Linkwarden/lib/api/controllers/links/postLink.ts
Normal file
@ -0,0 +1,209 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||
import getTitle from "@/lib/shared/getTitle";
|
||||
import { UsersAndCollections } from "@prisma/client";
|
||||
import getPermission from "@/lib/api/getPermission";
|
||||
import createFolder from "@/lib/api/storage/createFolder";
|
||||
import validateUrlSize from "../../validateUrlSize";
|
||||
|
||||
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
|
||||
|
||||
export default async function postLink(
|
||||
link: LinkIncludingShortenedCollectionAndTags,
|
||||
userId: number
|
||||
) {
|
||||
try {
|
||||
new URL(link.url || "");
|
||||
} catch (error) {
|
||||
return {
|
||||
response:
|
||||
"Please enter a valid Address for the Link. (It should start with http/https)",
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
|
||||
if (!link.collection.id && link.collection.name) {
|
||||
link.collection.name = link.collection.name.trim();
|
||||
|
||||
// find the collection with the name and the user's id
|
||||
const findCollection = await prisma.collection.findFirst({
|
||||
where: {
|
||||
name: link.collection.name,
|
||||
ownerId: userId,
|
||||
parentId: link.collection.parentId,
|
||||
},
|
||||
});
|
||||
|
||||
if (findCollection) {
|
||||
const collectionIsAccessible = await getPermission({
|
||||
userId,
|
||||
collectionId: findCollection.id,
|
||||
});
|
||||
|
||||
const memberHasAccess = collectionIsAccessible?.members.some(
|
||||
(e: UsersAndCollections) => e.userId === userId && e.canCreate
|
||||
);
|
||||
|
||||
if (!(collectionIsAccessible?.ownerId === userId || memberHasAccess))
|
||||
return { response: "Collection is not accessible.", status: 401 };
|
||||
|
||||
link.collection.id = findCollection.id;
|
||||
link.collection.ownerId = findCollection.ownerId;
|
||||
} else {
|
||||
const collection = await prisma.collection.create({
|
||||
data: {
|
||||
name: link.collection.name,
|
||||
ownerId: userId,
|
||||
},
|
||||
});
|
||||
|
||||
link.collection.id = collection.id;
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
collectionOrder: {
|
||||
push: link.collection.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (link.collection.id) {
|
||||
const collectionIsAccessible = await getPermission({
|
||||
userId,
|
||||
collectionId: link.collection.id,
|
||||
});
|
||||
|
||||
const memberHasAccess = collectionIsAccessible?.members.some(
|
||||
(e: UsersAndCollections) => e.userId === userId && e.canCreate
|
||||
);
|
||||
|
||||
if (!(collectionIsAccessible?.ownerId === userId || memberHasAccess))
|
||||
return { response: "Collection is not accessible.", status: 401 };
|
||||
} else if (!link.collection.id) {
|
||||
link.collection.name = "Unorganized";
|
||||
link.collection.parentId = null;
|
||||
|
||||
// find the collection with the name "Unorganized" and the user's id
|
||||
const unorganizedCollection = await prisma.collection.findFirst({
|
||||
where: {
|
||||
name: "Unorganized",
|
||||
ownerId: userId,
|
||||
},
|
||||
});
|
||||
|
||||
link.collection.id = unorganizedCollection?.id;
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
collectionOrder: {
|
||||
push: link.collection.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return { response: "Uncaught error.", status: 500 };
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (user?.preventDuplicateLinks) {
|
||||
const existingLink = await prisma.link.findFirst({
|
||||
where: {
|
||||
url: link.url?.trim(),
|
||||
collection: {
|
||||
ownerId: userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingLink)
|
||||
return {
|
||||
response: "Link already exists",
|
||||
status: 409,
|
||||
};
|
||||
}
|
||||
|
||||
const numberOfLinksTheUserHas = await prisma.link.count({
|
||||
where: {
|
||||
collection: {
|
||||
ownerId: userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (numberOfLinksTheUserHas + 1 > MAX_LINKS_PER_USER)
|
||||
return {
|
||||
response: `Error: Each user can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
|
||||
status: 400,
|
||||
};
|
||||
|
||||
link.collection.name = link.collection.name.trim();
|
||||
|
||||
const description =
|
||||
link.description && link.description !== ""
|
||||
? link.description
|
||||
: link.url
|
||||
? await getTitle(link.url)
|
||||
: undefined;
|
||||
|
||||
const validatedUrl = link.url ? await validateUrlSize(link.url) : undefined;
|
||||
|
||||
const contentType = validatedUrl?.get("content-type");
|
||||
let linkType = "url";
|
||||
let imageExtension = "png";
|
||||
|
||||
if (!link.url) linkType = link.type;
|
||||
else if (contentType === "application/pdf") linkType = "pdf";
|
||||
else if (contentType?.startsWith("image")) {
|
||||
linkType = "image";
|
||||
if (contentType === "image/jpeg") imageExtension = "jpeg";
|
||||
else if (contentType === "image/png") imageExtension = "png";
|
||||
}
|
||||
|
||||
const newLink = await prisma.link.create({
|
||||
data: {
|
||||
url: link.url?.trim(),
|
||||
name: link.name,
|
||||
description,
|
||||
type: linkType,
|
||||
collection: {
|
||||
connect: {
|
||||
id: link.collection.id,
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
connectOrCreate: link.tags.map((tag) => ({
|
||||
where: {
|
||||
name_ownerId: {
|
||||
name: tag.name.trim(),
|
||||
ownerId: link.collection.ownerId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
name: tag.name.trim(),
|
||||
owner: {
|
||||
connect: {
|
||||
id: link.collection.ownerId,
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { tags: true, collection: true },
|
||||
});
|
||||
|
||||
createFolder({ filePath: `archives/${newLink.collectionId}` });
|
||||
|
||||
return { response: newLink, status: 200 };
|
||||
}
|
39
Linkwarden/lib/api/controllers/migration/exportData.ts
Normal file
39
Linkwarden/lib/api/controllers/migration/exportData.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
|
||||
export default async function exportData(userId: number) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
collections: {
|
||||
include: {
|
||||
links: {
|
||||
include: {
|
||||
tags: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
pinnedLinks: true,
|
||||
whitelistedUsers: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) return { response: "User not found.", status: 404 };
|
||||
|
||||
const { password, id, ...userData } = user;
|
||||
|
||||
function redactIds(obj: any) {
|
||||
if (Array.isArray(obj)) {
|
||||
obj.forEach((o) => redactIds(o));
|
||||
} else if (obj !== null && typeof obj === "object") {
|
||||
delete obj.id;
|
||||
for (let key in obj) {
|
||||
redactIds(obj[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
redactIds(userData);
|
||||
|
||||
return { response: userData, status: 200 };
|
||||
}
|
266
Linkwarden/lib/api/controllers/migration/importFromHTMLFile.ts
Normal file
266
Linkwarden/lib/api/controllers/migration/importFromHTMLFile.ts
Normal file
@ -0,0 +1,266 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import createFolder from "@/lib/api/storage/createFolder";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { parse, Node, Element, TextNode } from "himalaya";
|
||||
import { writeFileSync } from "fs";
|
||||
|
||||
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
|
||||
|
||||
export default async function importFromHTMLFile(
|
||||
userId: number,
|
||||
rawData: string
|
||||
) {
|
||||
const dom = new JSDOM(rawData);
|
||||
const document = dom.window.document;
|
||||
|
||||
// remove bad tags
|
||||
document.querySelectorAll("meta").forEach((e) => (e.outerHTML = e.innerHTML));
|
||||
document.querySelectorAll("META").forEach((e) => (e.outerHTML = e.innerHTML));
|
||||
document.querySelectorAll("P").forEach((e) => (e.outerHTML = e.innerHTML));
|
||||
|
||||
const bookmarks = document.querySelectorAll("A");
|
||||
const totalImports = bookmarks.length;
|
||||
|
||||
const numberOfLinksTheUserHas = await prisma.link.count({
|
||||
where: {
|
||||
collection: {
|
||||
ownerId: userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (totalImports + numberOfLinksTheUserHas > MAX_LINKS_PER_USER)
|
||||
return {
|
||||
response: `Error: Each user can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
|
||||
status: 400,
|
||||
};
|
||||
|
||||
const jsonData = parse(document.documentElement.outerHTML);
|
||||
|
||||
const processedArray = processNodes(jsonData);
|
||||
|
||||
for (const item of processedArray) {
|
||||
console.log(item);
|
||||
await processBookmarks(userId, item as Element);
|
||||
}
|
||||
|
||||
return { response: "Success.", status: 200 };
|
||||
}
|
||||
|
||||
async function processBookmarks(
|
||||
userId: number,
|
||||
data: Node,
|
||||
parentCollectionId?: number
|
||||
) {
|
||||
if (data.type === "element") {
|
||||
for (const item of data.children) {
|
||||
if (item.type === "element" && item.tagName === "dt") {
|
||||
// process collection or sub-collection
|
||||
|
||||
let collectionId;
|
||||
const collectionName = item.children.find(
|
||||
(e) => e.type === "element" && e.tagName === "h3"
|
||||
) as Element;
|
||||
|
||||
if (collectionName) {
|
||||
collectionId = await createCollection(
|
||||
userId,
|
||||
(collectionName.children[0] as TextNode).content,
|
||||
parentCollectionId
|
||||
);
|
||||
}
|
||||
await processBookmarks(
|
||||
userId,
|
||||
item,
|
||||
collectionId || parentCollectionId
|
||||
);
|
||||
} else if (item.type === "element" && item.tagName === "a") {
|
||||
// process link
|
||||
|
||||
const linkUrl = item?.attributes.find(
|
||||
(e) => e.key.toLowerCase() === "href"
|
||||
)?.value;
|
||||
const linkName = (
|
||||
item?.children.find((e) => e.type === "text") as TextNode
|
||||
)?.content;
|
||||
const linkTags = item?.attributes
|
||||
.find((e) => e.key === "tags")
|
||||
?.value.split(",");
|
||||
|
||||
// set date if available
|
||||
const linkDateValue = item?.attributes.find(
|
||||
(e) => e.key.toLowerCase() === "add_date"
|
||||
)?.value;
|
||||
|
||||
const linkDate = linkDateValue
|
||||
? new Date(Number(linkDateValue) * 1000)
|
||||
: undefined;
|
||||
|
||||
let linkDesc =
|
||||
(
|
||||
(
|
||||
item?.children?.find(
|
||||
(e) => e.type === "element" && e.tagName === "dd"
|
||||
) as Element
|
||||
)?.children[0] as TextNode
|
||||
)?.content || "";
|
||||
|
||||
if (linkUrl && parentCollectionId) {
|
||||
await createLink(
|
||||
userId,
|
||||
linkUrl,
|
||||
parentCollectionId,
|
||||
linkName,
|
||||
linkDesc,
|
||||
linkTags,
|
||||
linkDate
|
||||
);
|
||||
} else if (linkUrl) {
|
||||
// create a collection named "Imported Bookmarks" and add the link to it
|
||||
const collectionId = await createCollection(userId, "Imports");
|
||||
|
||||
await createLink(
|
||||
userId,
|
||||
linkUrl,
|
||||
collectionId,
|
||||
linkName,
|
||||
linkDesc,
|
||||
linkTags,
|
||||
linkDate
|
||||
);
|
||||
}
|
||||
|
||||
await processBookmarks(userId, item, parentCollectionId);
|
||||
} else {
|
||||
// process anything else
|
||||
await processBookmarks(userId, item, parentCollectionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const createCollection = async (
|
||||
userId: number,
|
||||
collectionName: string,
|
||||
parentId?: number
|
||||
) => {
|
||||
const findCollection = await prisma.collection.findFirst({
|
||||
where: {
|
||||
parentId,
|
||||
name: collectionName,
|
||||
ownerId: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (findCollection) {
|
||||
return findCollection.id;
|
||||
}
|
||||
|
||||
const collectionId = await prisma.collection.create({
|
||||
data: {
|
||||
name: collectionName,
|
||||
parent: parentId
|
||||
? {
|
||||
connect: {
|
||||
id: parentId,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
owner: {
|
||||
connect: {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
createFolder({ filePath: `archives/${collectionId.id}` });
|
||||
|
||||
return collectionId.id;
|
||||
};
|
||||
|
||||
const createLink = async (
|
||||
userId: number,
|
||||
url: string,
|
||||
collectionId: number,
|
||||
name?: string,
|
||||
description?: string,
|
||||
tags?: string[],
|
||||
importDate?: Date
|
||||
) => {
|
||||
await prisma.link.create({
|
||||
data: {
|
||||
name: name || "",
|
||||
url,
|
||||
description,
|
||||
collectionId,
|
||||
tags:
|
||||
tags && tags[0]
|
||||
? {
|
||||
connectOrCreate: tags.map((tag: string) => {
|
||||
return (
|
||||
{
|
||||
where: {
|
||||
name_ownerId: {
|
||||
name: tag.trim(),
|
||||
ownerId: userId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
name: tag.trim(),
|
||||
owner: {
|
||||
connect: {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
} || undefined
|
||||
);
|
||||
}),
|
||||
}
|
||||
: undefined,
|
||||
importDate: importDate || undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
function processNodes(nodes: Node[]) {
|
||||
const findAndProcessDL = (node: Node) => {
|
||||
if (node.type === "element" && node.tagName === "dl") {
|
||||
processDLChildren(node);
|
||||
} else if (
|
||||
node.type === "element" &&
|
||||
node.children &&
|
||||
node.children.length
|
||||
) {
|
||||
node.children.forEach((child) => findAndProcessDL(child));
|
||||
}
|
||||
};
|
||||
|
||||
const processDLChildren = (dlNode: Element) => {
|
||||
dlNode.children.forEach((child, i) => {
|
||||
if (child.type === "element" && child.tagName === "dt") {
|
||||
const nextSibling = dlNode.children[i + 1];
|
||||
if (
|
||||
nextSibling &&
|
||||
nextSibling.type === "element" &&
|
||||
nextSibling.tagName === "dd"
|
||||
) {
|
||||
const aElement = child.children.find(
|
||||
(el) => el.type === "element" && el.tagName === "a"
|
||||
);
|
||||
if (aElement && aElement.type === "element") {
|
||||
// Add the 'dd' element as a child of the 'a' element
|
||||
aElement.children.push(nextSibling);
|
||||
// Remove the 'dd' from the parent 'dl' to avoid duplicate processing
|
||||
dlNode.children.splice(i + 1, 1);
|
||||
// Adjust the loop counter due to the removal
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
nodes.forEach(findAndProcessDL);
|
||||
return nodes;
|
||||
}
|
@ -0,0 +1,96 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import { Backup } from "@/types/global";
|
||||
import createFolder from "@/lib/api/storage/createFolder";
|
||||
|
||||
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
|
||||
|
||||
export default async function importFromLinkwarden(
|
||||
userId: number,
|
||||
rawData: string
|
||||
) {
|
||||
const data: Backup = JSON.parse(rawData);
|
||||
|
||||
let totalImports = 0;
|
||||
|
||||
data.collections.forEach((collection) => {
|
||||
totalImports += collection.links.length;
|
||||
});
|
||||
|
||||
const numberOfLinksTheUserHas = await prisma.link.count({
|
||||
where: {
|
||||
collection: {
|
||||
ownerId: userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (totalImports + numberOfLinksTheUserHas > MAX_LINKS_PER_USER)
|
||||
return {
|
||||
response: `Error: Each user can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
|
||||
status: 400,
|
||||
};
|
||||
|
||||
await prisma
|
||||
.$transaction(
|
||||
async () => {
|
||||
// Import collections
|
||||
for (const e of data.collections) {
|
||||
e.name = e.name.trim();
|
||||
|
||||
const newCollection = await prisma.collection.create({
|
||||
data: {
|
||||
owner: {
|
||||
connect: {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
name: e.name,
|
||||
description: e.description,
|
||||
color: e.color,
|
||||
},
|
||||
});
|
||||
|
||||
createFolder({ filePath: `archives/${newCollection.id}` });
|
||||
|
||||
// Import Links
|
||||
for (const link of e.links) {
|
||||
const newLink = await prisma.link.create({
|
||||
data: {
|
||||
url: link.url,
|
||||
name: link.name,
|
||||
description: link.description,
|
||||
collection: {
|
||||
connect: {
|
||||
id: newCollection.id,
|
||||
},
|
||||
},
|
||||
// Import Tags
|
||||
tags: {
|
||||
connectOrCreate: link.tags.map((tag) => ({
|
||||
where: {
|
||||
name_ownerId: {
|
||||
name: tag.name.trim(),
|
||||
ownerId: userId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
name: tag.name.trim(),
|
||||
owner: {
|
||||
connect: {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
{ timeout: 30000 }
|
||||
)
|
||||
.catch((err) => console.log(err));
|
||||
|
||||
return { response: "Success.", status: 200 };
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
|
||||
export default async function getPublicCollection(id: number) {
|
||||
const collection = await prisma.collection.findFirst({
|
||||
where: {
|
||||
id,
|
||||
isPublic: true,
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
username: true,
|
||||
name: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { links: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (collection) {
|
||||
return { response: collection, status: 200 };
|
||||
} else {
|
||||
return { response: "Collection not found.", status: 400 };
|
||||
}
|
||||
}
|
@ -0,0 +1,88 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import { LinkRequestQuery, Sort } from "@/types/global";
|
||||
|
||||
export default async function getLink(
|
||||
query: Omit<LinkRequestQuery, "tagId" | "pinnedOnly">
|
||||
) {
|
||||
const POSTGRES_IS_ENABLED = process.env.DATABASE_URL.startsWith("postgresql");
|
||||
|
||||
let order: any;
|
||||
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
|
||||
else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
|
||||
else if (query.sort === Sort.NameAZ) order = { name: "asc" };
|
||||
else if (query.sort === Sort.NameZA) order = { name: "desc" };
|
||||
else if (query.sort === Sort.DescriptionAZ) order = { description: "asc" };
|
||||
else if (query.sort === Sort.DescriptionZA) order = { description: "desc" };
|
||||
|
||||
const searchConditions = [];
|
||||
|
||||
if (query.searchQueryString) {
|
||||
if (query.searchByName) {
|
||||
searchConditions.push({
|
||||
name: {
|
||||
contains: query.searchQueryString,
|
||||
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (query.searchByUrl) {
|
||||
searchConditions.push({
|
||||
url: {
|
||||
contains: query.searchQueryString,
|
||||
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (query.searchByDescription) {
|
||||
searchConditions.push({
|
||||
description: {
|
||||
contains: query.searchQueryString,
|
||||
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (query.searchByTextContent) {
|
||||
searchConditions.push({
|
||||
textContent: {
|
||||
contains: query.searchQueryString,
|
||||
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (query.searchByTags) {
|
||||
searchConditions.push({
|
||||
tags: {
|
||||
some: {
|
||||
name: {
|
||||
contains: query.searchQueryString,
|
||||
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const links = await prisma.link.findMany({
|
||||
take: Number(process.env.PAGINATION_TAKE_COUNT) || 20,
|
||||
skip: query.cursor ? 1 : undefined,
|
||||
cursor: query.cursor ? { id: query.cursor } : undefined,
|
||||
where: {
|
||||
collection: {
|
||||
id: query.collectionId,
|
||||
isPublic: true,
|
||||
},
|
||||
[query.searchQueryString ? "OR" : "AND"]: [...searchConditions],
|
||||
},
|
||||
include: {
|
||||
tags: true,
|
||||
},
|
||||
orderBy: order || { id: "desc" },
|
||||
});
|
||||
|
||||
return { response: links, status: 200 };
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
|
||||
export default async function getLinkById(linkId: number) {
|
||||
if (!linkId)
|
||||
return {
|
||||
response: "Please choose a valid link.",
|
||||
status: 401,
|
||||
};
|
||||
|
||||
const link = await prisma.link.findFirst({
|
||||
where: {
|
||||
id: linkId,
|
||||
collection: {
|
||||
isPublic: true,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
tags: true,
|
||||
collection: true,
|
||||
},
|
||||
});
|
||||
|
||||
return { response: link, status: 200 };
|
||||
}
|
82
Linkwarden/lib/api/controllers/public/users/getPublicUser.ts
Normal file
82
Linkwarden/lib/api/controllers/public/users/getPublicUser.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
|
||||
export default async function getPublicUser(
|
||||
targetId: number | string,
|
||||
isId: boolean,
|
||||
requestingId?: number
|
||||
) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: isId
|
||||
? {
|
||||
id: Number(targetId) as number,
|
||||
}
|
||||
: {
|
||||
username: targetId as string,
|
||||
},
|
||||
include: {
|
||||
whitelistedUsers: {
|
||||
select: {
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user)
|
||||
return { response: "User not found or profile is private.", status: 404 };
|
||||
|
||||
const whitelistedUsernames = user.whitelistedUsers?.map(
|
||||
(usernames) => usernames.username
|
||||
);
|
||||
|
||||
const isInAPublicCollection = await prisma.collection.findFirst({
|
||||
where: {
|
||||
["OR"]: [
|
||||
{ ownerId: user.id },
|
||||
{
|
||||
members: {
|
||||
some: {
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
isPublic: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (user?.isPrivate && !isInAPublicCollection) {
|
||||
if (requestingId) {
|
||||
const requestingUser = await prisma.user.findUnique({
|
||||
where: { id: requestingId },
|
||||
});
|
||||
|
||||
if (
|
||||
requestingUser?.id !== requestingId &&
|
||||
(!requestingUser?.username ||
|
||||
!whitelistedUsernames.includes(
|
||||
requestingUser.username?.toLowerCase()
|
||||
))
|
||||
) {
|
||||
return {
|
||||
response: "User not found or profile is private.",
|
||||
status: 404,
|
||||
};
|
||||
}
|
||||
} else
|
||||
return { response: "User not found or profile is private.", status: 404 };
|
||||
}
|
||||
|
||||
const { password, ...lessSensitiveInfo } = user;
|
||||
|
||||
const data = {
|
||||
id: lessSensitiveInfo.id,
|
||||
name: lessSensitiveInfo.name,
|
||||
username: lessSensitiveInfo.username,
|
||||
image: lessSensitiveInfo.image,
|
||||
archiveAsScreenshot: lessSensitiveInfo.archiveAsScreenshot,
|
||||
archiveAsPDF: lessSensitiveInfo.archiveAsPDF,
|
||||
};
|
||||
|
||||
return { response: data, status: 200 };
|
||||
}
|
46
Linkwarden/lib/api/controllers/tags/getTags.ts
Normal file
46
Linkwarden/lib/api/controllers/tags/getTags.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
|
||||
export default async function getTags(userId: number) {
|
||||
// Remove empty tags
|
||||
await prisma.tag.deleteMany({
|
||||
where: {
|
||||
ownerId: userId,
|
||||
links: {
|
||||
none: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const tags = await prisma.tag.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ ownerId: userId }, // Tags owned by the user
|
||||
{
|
||||
links: {
|
||||
some: {
|
||||
collection: {
|
||||
members: {
|
||||
some: {
|
||||
userId, // Tags from collections where the user is a member
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { links: true },
|
||||
},
|
||||
},
|
||||
// orderBy: {
|
||||
// links: {
|
||||
// _count: "desc",
|
||||
// },
|
||||
// },
|
||||
});
|
||||
|
||||
return { response: tags, status: 200 };
|
||||
}
|
26
Linkwarden/lib/api/controllers/tags/tagId/deleteTagById.ts
Normal file
26
Linkwarden/lib/api/controllers/tags/tagId/deleteTagById.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
|
||||
export default async function deleteTagById(userId: number, tagId: number) {
|
||||
if (!tagId)
|
||||
return { response: "Please choose a valid name for the tag.", status: 401 };
|
||||
|
||||
const targetTag = await prisma.tag.findUnique({
|
||||
where: {
|
||||
id: tagId,
|
||||
},
|
||||
});
|
||||
|
||||
if (targetTag?.ownerId !== userId)
|
||||
return {
|
||||
response: "Permission denied.",
|
||||
status: 401,
|
||||
};
|
||||
|
||||
const updatedTag = await prisma.tag.delete({
|
||||
where: {
|
||||
id: tagId,
|
||||
},
|
||||
});
|
||||
|
||||
return { response: updatedTag, status: 200 };
|
||||
}
|
47
Linkwarden/lib/api/controllers/tags/tagId/updeteTagById.ts
Normal file
47
Linkwarden/lib/api/controllers/tags/tagId/updeteTagById.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import { Tag } from "@prisma/client";
|
||||
|
||||
export default async function updeteTagById(
|
||||
userId: number,
|
||||
tagId: number,
|
||||
data: Tag
|
||||
) {
|
||||
if (!tagId || !data.name)
|
||||
return { response: "Please choose a valid name for the tag.", status: 401 };
|
||||
|
||||
const tagNameIsTaken = await prisma.tag.findFirst({
|
||||
where: {
|
||||
ownerId: userId,
|
||||
name: data.name,
|
||||
},
|
||||
});
|
||||
|
||||
if (tagNameIsTaken)
|
||||
return {
|
||||
response: "Tag names should be unique.",
|
||||
status: 400,
|
||||
};
|
||||
|
||||
const targetTag = await prisma.tag.findUnique({
|
||||
where: {
|
||||
id: tagId,
|
||||
},
|
||||
});
|
||||
|
||||
if (targetTag?.ownerId !== userId)
|
||||
return {
|
||||
response: "Permission denied.",
|
||||
status: 401,
|
||||
};
|
||||
|
||||
const updatedTag = await prisma.tag.update({
|
||||
where: {
|
||||
id: tagId,
|
||||
},
|
||||
data: {
|
||||
name: data.name,
|
||||
},
|
||||
});
|
||||
|
||||
return { response: updatedTag, status: 200 };
|
||||
}
|
21
Linkwarden/lib/api/controllers/tokens/getTokens.ts
Normal file
21
Linkwarden/lib/api/controllers/tokens/getTokens.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
|
||||
export default async function getToken(userId: number) {
|
||||
const getTokens = await prisma.accessToken.findMany({
|
||||
where: {
|
||||
userId,
|
||||
revoked: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
expires: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
response: getTokens,
|
||||
status: 200,
|
||||
};
|
||||
}
|
92
Linkwarden/lib/api/controllers/tokens/postToken.ts
Normal file
92
Linkwarden/lib/api/controllers/tokens/postToken.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import { TokenExpiry } from "@/types/global";
|
||||
import crypto from "crypto";
|
||||
import { decode, encode } from "next-auth/jwt";
|
||||
|
||||
export default async function postToken(
|
||||
body: {
|
||||
name: string;
|
||||
expires: TokenExpiry;
|
||||
},
|
||||
userId: number
|
||||
) {
|
||||
console.log(body);
|
||||
|
||||
const checkHasEmptyFields = !body.name || body.expires === undefined;
|
||||
|
||||
if (checkHasEmptyFields)
|
||||
return {
|
||||
response: "Please fill out all the fields.",
|
||||
status: 400,
|
||||
};
|
||||
|
||||
const checkIfTokenExists = await prisma.accessToken.findFirst({
|
||||
where: {
|
||||
name: body.name,
|
||||
revoked: false,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (checkIfTokenExists) {
|
||||
return {
|
||||
response: "Token with that name already exists.",
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
let expiryDate = new Date();
|
||||
const oneDayInSeconds = 86400;
|
||||
let expiryDateSecond = 7 * oneDayInSeconds;
|
||||
|
||||
if (body.expires === TokenExpiry.oneMonth) {
|
||||
expiryDate.setDate(expiryDate.getDate() + 30);
|
||||
expiryDateSecond = 30 * oneDayInSeconds;
|
||||
} else if (body.expires === TokenExpiry.twoMonths) {
|
||||
expiryDate.setDate(expiryDate.getDate() + 60);
|
||||
expiryDateSecond = 60 * oneDayInSeconds;
|
||||
} else if (body.expires === TokenExpiry.threeMonths) {
|
||||
expiryDate.setDate(expiryDate.getDate() + 90);
|
||||
expiryDateSecond = 90 * oneDayInSeconds;
|
||||
} else if (body.expires === TokenExpiry.never) {
|
||||
expiryDate.setDate(expiryDate.getDate() + 73000); // 200 years (not really never)
|
||||
expiryDateSecond = 73050 * oneDayInSeconds;
|
||||
} else {
|
||||
expiryDate.setDate(expiryDate.getDate() + 7);
|
||||
expiryDateSecond = 7 * oneDayInSeconds;
|
||||
}
|
||||
|
||||
const token = await encode({
|
||||
token: {
|
||||
id: userId,
|
||||
iat: now / 1000,
|
||||
exp: (expiryDate as any) / 1000,
|
||||
jti: crypto.randomUUID(),
|
||||
},
|
||||
maxAge: expiryDateSecond || 604800,
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
});
|
||||
|
||||
const tokenBody = await decode({
|
||||
token,
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
});
|
||||
|
||||
const createToken = await prisma.accessToken.create({
|
||||
data: {
|
||||
name: body.name,
|
||||
userId,
|
||||
token: tokenBody?.jti as string,
|
||||
expires: expiryDate,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
response: {
|
||||
secretKey: token,
|
||||
token: createToken,
|
||||
},
|
||||
status: 200,
|
||||
};
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
|
||||
export default async function deleteToken(userId: number, tokenId: number) {
|
||||
if (!tokenId)
|
||||
return { response: "Please choose a valid token.", status: 401 };
|
||||
|
||||
const tokenExists = await prisma.accessToken.findFirst({
|
||||
where: {
|
||||
id: tokenId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
const revokedToken = await prisma.accessToken.update({
|
||||
where: {
|
||||
id: tokenExists?.id,
|
||||
},
|
||||
data: {
|
||||
revoked: true,
|
||||
},
|
||||
});
|
||||
|
||||
return { response: revokedToken, status: 200 };
|
||||
}
|
91
Linkwarden/lib/api/controllers/users/postUser.ts
Normal file
91
Linkwarden/lib/api/controllers/users/postUser.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import bcrypt from "bcrypt";
|
||||
|
||||
const emailEnabled =
|
||||
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
|
||||
|
||||
interface Data {
|
||||
response: string | object;
|
||||
}
|
||||
|
||||
interface User {
|
||||
name: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export default async function postUser(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<Data>
|
||||
) {
|
||||
if (process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true") {
|
||||
return res.status(400).json({ response: "Registration is disabled." });
|
||||
}
|
||||
|
||||
const body: User = req.body;
|
||||
|
||||
const checkHasEmptyFields = emailEnabled
|
||||
? !body.password || !body.name || !body.email
|
||||
: !body.username || !body.password || !body.name;
|
||||
|
||||
if (!body.password || body.password.length < 8)
|
||||
return res
|
||||
.status(400)
|
||||
.json({ response: "Password must be at least 8 characters." });
|
||||
|
||||
if (checkHasEmptyFields)
|
||||
return res
|
||||
.status(400)
|
||||
.json({ response: "Please fill out all the fields." });
|
||||
|
||||
// Check email (if enabled)
|
||||
const checkEmail =
|
||||
/^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;
|
||||
if (emailEnabled && !checkEmail.test(body.email?.toLowerCase() || ""))
|
||||
return res.status(400).json({
|
||||
response: "Please enter a valid email.",
|
||||
});
|
||||
|
||||
// Check username (if email was disabled)
|
||||
const checkUsername = RegExp("^[a-z0-9_-]{3,31}$");
|
||||
if (!emailEnabled && !checkUsername.test(body.username?.toLowerCase() || ""))
|
||||
return res.status(400).json({
|
||||
response:
|
||||
"Username has to be between 3-30 characters, no spaces and special characters are allowed.",
|
||||
});
|
||||
|
||||
const checkIfUserExists = await prisma.user.findFirst({
|
||||
where: emailEnabled
|
||||
? {
|
||||
email: body.email?.toLowerCase().trim(),
|
||||
}
|
||||
: {
|
||||
username: (body.username as string).toLowerCase().trim(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!checkIfUserExists) {
|
||||
const saltRounds = 10;
|
||||
|
||||
const hashedPassword = bcrypt.hashSync(body.password, saltRounds);
|
||||
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
name: body.name,
|
||||
username: emailEnabled
|
||||
? undefined
|
||||
: (body.username as string).toLowerCase().trim(),
|
||||
email: emailEnabled ? body.email?.toLowerCase().trim() : undefined,
|
||||
password: hashedPassword,
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(201).json({ response: "User successfully created." });
|
||||
} else if (checkIfUserExists) {
|
||||
return res.status(400).json({
|
||||
response: `${emailEnabled ? "Email" : "Username"} already exists.`,
|
||||
});
|
||||
}
|
||||
}
|
141
Linkwarden/lib/api/controllers/users/userId/deleteUserById.ts
Normal file
141
Linkwarden/lib/api/controllers/users/userId/deleteUserById.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import bcrypt from "bcrypt";
|
||||
import removeFolder from "@/lib/api/storage/removeFolder";
|
||||
import Stripe from "stripe";
|
||||
import { DeleteUserBody } from "@/types/global";
|
||||
import removeFile from "@/lib/api/storage/removeFile";
|
||||
|
||||
const keycloakEnabled = process.env.KEYCLOAK_CLIENT_SECRET;
|
||||
const authentikEnabled = process.env.AUTHENTIK_CLIENT_SECRET;
|
||||
|
||||
export default async function deleteUserById(
|
||||
userId: number,
|
||||
body: DeleteUserBody
|
||||
) {
|
||||
// First, we retrieve the user from the database
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
response: "Invalid credentials.",
|
||||
status: 404,
|
||||
};
|
||||
}
|
||||
|
||||
// Then, we check if the provided password matches the one stored in the database (disabled in Keycloak integration)
|
||||
if (!keycloakEnabled && !authentikEnabled) {
|
||||
const isPasswordValid = bcrypt.compareSync(
|
||||
body.password,
|
||||
user.password as string
|
||||
);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
return {
|
||||
response: "Invalid credentials.",
|
||||
status: 401, // Unauthorized
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the user and all related data within a transaction
|
||||
await prisma
|
||||
.$transaction(
|
||||
async (prisma) => {
|
||||
// Delete whitelisted users
|
||||
await prisma.whitelistedUser.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
// Delete links
|
||||
await prisma.link.deleteMany({
|
||||
where: { collection: { ownerId: userId } },
|
||||
});
|
||||
|
||||
// Delete tags
|
||||
await prisma.tag.deleteMany({
|
||||
where: { ownerId: userId },
|
||||
});
|
||||
|
||||
// Find collections that the user owns
|
||||
const collections = await prisma.collection.findMany({
|
||||
where: { ownerId: userId },
|
||||
});
|
||||
|
||||
for (const collection of collections) {
|
||||
// Delete related users and collections relations
|
||||
await prisma.usersAndCollections.deleteMany({
|
||||
where: { collectionId: collection.id },
|
||||
});
|
||||
|
||||
// Delete archive folders
|
||||
removeFolder({ filePath: `archives/${collection.id}` });
|
||||
}
|
||||
|
||||
// Delete collections after cleaning up related data
|
||||
await prisma.collection.deleteMany({
|
||||
where: { ownerId: userId },
|
||||
});
|
||||
|
||||
// Delete subscription
|
||||
if (process.env.STRIPE_SECRET_KEY)
|
||||
await prisma.subscription.delete({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
await prisma.usersAndCollections.deleteMany({
|
||||
where: {
|
||||
OR: [{ userId: userId }, { collection: { ownerId: userId } }],
|
||||
},
|
||||
});
|
||||
|
||||
// Delete user's avatar
|
||||
await removeFile({ filePath: `uploads/avatar/${userId}.jpg` });
|
||||
|
||||
// Finally, delete the user
|
||||
await prisma.user.delete({
|
||||
where: { id: userId },
|
||||
});
|
||||
},
|
||||
{ timeout: 20000 }
|
||||
)
|
||||
.catch((err) => console.log(err));
|
||||
|
||||
if (process.env.STRIPE_SECRET_KEY) {
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: "2022-11-15",
|
||||
});
|
||||
|
||||
try {
|
||||
const listByEmail = await stripe.customers.list({
|
||||
email: user.email?.toLowerCase(),
|
||||
expand: ["data.subscriptions"],
|
||||
});
|
||||
|
||||
if (listByEmail.data[0].subscriptions?.data[0].id) {
|
||||
const deleted = await stripe.subscriptions.cancel(
|
||||
listByEmail.data[0].subscriptions?.data[0].id,
|
||||
{
|
||||
cancellation_details: {
|
||||
comment: body.cancellation_details?.comment,
|
||||
feedback: body.cancellation_details?.feedback,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
response: deleted,
|
||||
status: 200,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
response: "User account and all related data deleted successfully.",
|
||||
status: 200,
|
||||
};
|
||||
}
|
36
Linkwarden/lib/api/controllers/users/userId/getUserById.ts
Normal file
36
Linkwarden/lib/api/controllers/users/userId/getUserById.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
|
||||
export default async function getUserById(userId: number) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
whitelistedUsers: {
|
||||
select: {
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
subscriptions: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user)
|
||||
return { response: "User not found or profile is private.", status: 404 };
|
||||
|
||||
const whitelistedUsernames = user.whitelistedUsers?.map(
|
||||
(usernames) => usernames.username
|
||||
);
|
||||
|
||||
const { password, subscriptions, ...lessSensitiveInfo } = user;
|
||||
|
||||
const data = {
|
||||
...lessSensitiveInfo,
|
||||
whitelistedUsers: whitelistedUsernames,
|
||||
subscription: {
|
||||
active: subscriptions?.active,
|
||||
},
|
||||
};
|
||||
|
||||
return { response: data, status: 200 };
|
||||
}
|
264
Linkwarden/lib/api/controllers/users/userId/updateUserById.ts
Normal file
264
Linkwarden/lib/api/controllers/users/userId/updateUserById.ts
Normal file
@ -0,0 +1,264 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import { AccountSettings } from "@/types/global";
|
||||
import bcrypt from "bcrypt";
|
||||
import removeFile from "@/lib/api/storage/removeFile";
|
||||
import createFile from "@/lib/api/storage/createFile";
|
||||
import updateCustomerEmail from "@/lib/api/updateCustomerEmail";
|
||||
import createFolder from "@/lib/api/storage/createFolder";
|
||||
|
||||
const emailEnabled =
|
||||
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
|
||||
|
||||
export default async function updateUserById(
|
||||
userId: number,
|
||||
data: AccountSettings
|
||||
) {
|
||||
const ssoUser = await prisma.account.findFirst({
|
||||
where: {
|
||||
userId: userId,
|
||||
},
|
||||
});
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (ssoUser) {
|
||||
// deny changes to SSO-defined properties
|
||||
if (data.email !== user?.email) {
|
||||
return {
|
||||
response: "SSO users cannot change their email.",
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
if (data.newPassword) {
|
||||
return {
|
||||
response: "SSO Users cannot change their password.",
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
if (data.name !== user?.name) {
|
||||
return {
|
||||
response: "SSO Users cannot change their name.",
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
if (data.username !== user?.username) {
|
||||
return {
|
||||
response: "SSO Users cannot change their username.",
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
if (data.image?.startsWith("data:image/jpeg;base64")) {
|
||||
return {
|
||||
response: "SSO Users cannot change their avatar.",
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// verify only for non-SSO users
|
||||
// SSO users cannot change their email, password, name, username, or avatar
|
||||
if (emailEnabled && !data.email)
|
||||
return {
|
||||
response: "Email invalid.",
|
||||
status: 400,
|
||||
};
|
||||
else if (!data.username)
|
||||
return {
|
||||
response: "Username invalid.",
|
||||
status: 400,
|
||||
};
|
||||
if (data.newPassword && data.newPassword?.length < 8)
|
||||
return {
|
||||
response: "Password must be at least 8 characters.",
|
||||
status: 400,
|
||||
};
|
||||
// Check email (if enabled)
|
||||
const checkEmail =
|
||||
/^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;
|
||||
if (emailEnabled && !checkEmail.test(data.email?.toLowerCase() || ""))
|
||||
return {
|
||||
response: "Please enter a valid email.",
|
||||
status: 400,
|
||||
};
|
||||
|
||||
const checkUsername = RegExp("^[a-z0-9_-]{3,31}$");
|
||||
|
||||
if (!checkUsername.test(data.username.toLowerCase()))
|
||||
return {
|
||||
response:
|
||||
"Username has to be between 3-30 characters, no spaces and special characters are allowed.",
|
||||
status: 400,
|
||||
};
|
||||
|
||||
const userIsTaken = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: { not: userId },
|
||||
OR: emailEnabled
|
||||
? [
|
||||
{
|
||||
username: data.username.toLowerCase(),
|
||||
},
|
||||
{
|
||||
email: data.email?.toLowerCase(),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
username: data.username.toLowerCase(),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (userIsTaken) {
|
||||
if (data.email?.toLowerCase().trim() === userIsTaken.email?.trim())
|
||||
return {
|
||||
response: "Email is taken.",
|
||||
status: 400,
|
||||
};
|
||||
else if (
|
||||
data.username?.toLowerCase().trim() === userIsTaken.username?.trim()
|
||||
)
|
||||
return {
|
||||
response: "Username is taken.",
|
||||
status: 400,
|
||||
};
|
||||
|
||||
return {
|
||||
response: "Username/Email is taken.",
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
|
||||
// Avatar Settings
|
||||
|
||||
if (data.image?.startsWith("data:image/jpeg;base64")) {
|
||||
if (data.image.length < 1572864) {
|
||||
try {
|
||||
const base64Data = data.image.replace(
|
||||
/^data:image\/jpeg;base64,/,
|
||||
""
|
||||
);
|
||||
|
||||
createFolder({ filePath: `uploads/avatar` });
|
||||
|
||||
await createFile({
|
||||
filePath: `uploads/avatar/${userId}.jpg`,
|
||||
data: base64Data,
|
||||
isBase64: true,
|
||||
});
|
||||
} catch (err) {
|
||||
console.log("Error saving image:", err);
|
||||
}
|
||||
} else {
|
||||
console.log("A file larger than 1.5MB was uploaded.");
|
||||
return {
|
||||
response: "A file larger than 1.5MB was uploaded.",
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
} else if (data.image == "") {
|
||||
removeFile({ filePath: `uploads/avatar/${userId}.jpg` });
|
||||
}
|
||||
}
|
||||
|
||||
const previousEmail = (
|
||||
await prisma.user.findUnique({ where: { id: userId } })
|
||||
)?.email;
|
||||
|
||||
// Other settings
|
||||
|
||||
const saltRounds = 10;
|
||||
const newHashedPassword = bcrypt.hashSync(data.newPassword || "", saltRounds);
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
name: data.name,
|
||||
username: data.username?.toLowerCase().trim(),
|
||||
email: data.email?.toLowerCase().trim(),
|
||||
isPrivate: data.isPrivate,
|
||||
image: data.image ? `uploads/avatar/${userId}.jpg` : "",
|
||||
collectionOrder: data.collectionOrder.filter(
|
||||
(value, index, self) => self.indexOf(value) === index
|
||||
),
|
||||
archiveAsScreenshot: data.archiveAsScreenshot,
|
||||
archiveAsPDF: data.archiveAsPDF,
|
||||
archiveAsWaybackMachine: data.archiveAsWaybackMachine,
|
||||
linksRouteTo: data.linksRouteTo,
|
||||
preventDuplicateLinks: data.preventDuplicateLinks,
|
||||
password:
|
||||
data.newPassword && data.newPassword !== ""
|
||||
? newHashedPassword
|
||||
: undefined,
|
||||
},
|
||||
include: {
|
||||
whitelistedUsers: true,
|
||||
subscriptions: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { whitelistedUsers, password, subscriptions, ...userInfo } =
|
||||
updatedUser;
|
||||
|
||||
// If user.whitelistedUsers is not provided, we will assume the whitelistedUsers should be removed
|
||||
const newWhitelistedUsernames: string[] = data.whitelistedUsers || [];
|
||||
|
||||
// Get the current whitelisted usernames
|
||||
const currentWhitelistedUsernames: string[] = whitelistedUsers.map(
|
||||
(data) => data.username
|
||||
);
|
||||
|
||||
// Find the usernames to be deleted (present in current but not in new)
|
||||
const usernamesToDelete: string[] = currentWhitelistedUsernames.filter(
|
||||
(username) => !newWhitelistedUsernames.includes(username)
|
||||
);
|
||||
|
||||
// Find the usernames to be created (present in new but not in current)
|
||||
const usernamesToCreate: string[] = newWhitelistedUsernames.filter(
|
||||
(username) =>
|
||||
!currentWhitelistedUsernames.includes(username) && username.trim() !== ""
|
||||
);
|
||||
|
||||
// Delete whitelistedUsers that are not present in the new list
|
||||
await prisma.whitelistedUser.deleteMany({
|
||||
where: {
|
||||
userId: userId,
|
||||
username: {
|
||||
in: usernamesToDelete,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create new whitelistedUsers that are not in the current list, no create many ;(
|
||||
for (const username of usernamesToCreate) {
|
||||
await prisma.whitelistedUser.create({
|
||||
data: {
|
||||
username,
|
||||
userId: userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
|
||||
|
||||
if (STRIPE_SECRET_KEY && emailEnabled && previousEmail !== data.email)
|
||||
await updateCustomerEmail(
|
||||
STRIPE_SECRET_KEY,
|
||||
previousEmail as string,
|
||||
data.email as string
|
||||
);
|
||||
|
||||
const response: Omit<AccountSettings, "password"> = {
|
||||
...userInfo,
|
||||
whitelistedUsers: newWhitelistedUsernames,
|
||||
image: userInfo.image ? `${userInfo.image}?${Date.now()}` : "",
|
||||
subscription: { active: subscriptions?.active },
|
||||
};
|
||||
|
||||
return { response, status: 200 };
|
||||
}
|
Reference in New Issue
Block a user