Merge branch 'feat/dark-mode' into feat/dark-mode
This commit is contained in:
commit
83349ea065
|
@ -0,0 +1,22 @@
|
|||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node
|
||||
{
|
||||
"name": "Node.js & TypeScript",
|
||||
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-20-bullseye",
|
||||
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
// "postCreateCommand": "yarn install",
|
||||
|
||||
// Configure tool-specific properties.
|
||||
// "customizations": {},
|
||||
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
"remoteUser": "root"
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
node_modules
|
||||
pgdata
|
||||
.env
|
||||
.devcontainer
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
README.md
|
|
@ -6,6 +6,7 @@ NEXTAUTH_URL=http://localhost:3000
|
|||
|
||||
PAGINATION_TAKE_COUNT=
|
||||
STORAGE_FOLDER=
|
||||
AUTOSCROLL_TIMEOUT=
|
||||
|
||||
# AWS S3 Settings
|
||||
SPACES_KEY=
|
||||
|
@ -25,4 +26,7 @@ PRICE_ID=
|
|||
NEXT_PUBLIC_TRIAL_PERIOD_DAYS=
|
||||
NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL=
|
||||
BASE_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_PRICING=
|
||||
NEXT_PUBLIC_PRICING=
|
||||
|
||||
# Docker postgres settings
|
||||
POSTGRES_PASSWORD=
|
||||
|
|
|
@ -36,9 +36,14 @@ next-env.d.ts
|
|||
|
||||
# generated files and folders
|
||||
/data
|
||||
.idea
|
||||
prisma/dev.db
|
||||
|
||||
# tests
|
||||
/tests
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/playwright/.cache/
|
||||
|
||||
# docker
|
||||
pgdata
|
|
@ -0,0 +1,27 @@
|
|||
# playwright doesnt support debian image
|
||||
FROM ubuntu:focal
|
||||
|
||||
RUN apt-get update && apt-get install wget xz-utils -y
|
||||
|
||||
RUN mkdir /data
|
||||
|
||||
WORKDIR /data
|
||||
|
||||
RUN wget https://nodejs.org/dist/v20.5.0/node-v20.5.0-linux-x64.tar.xz -O nodejs.tar.xz \
|
||||
&& mkdir /opt/nodejs \
|
||||
&& tar -xf nodejs.tar.xz --strip-components 1 -C /opt/nodejs \
|
||||
&& rm nodejs.tar.xz
|
||||
ENV PATH="$PATH:/opt/nodejs/bin"
|
||||
RUN npm install -g yarn
|
||||
|
||||
COPY ./package.json ./yarn.lock ./playwright.config.ts ./
|
||||
|
||||
RUN yarn
|
||||
RUN npx playwright install-deps
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN yarn prisma generate
|
||||
RUN yarn build
|
||||
|
||||
CMD yarn prisma migrate deploy && yarn start
|
|
@ -11,7 +11,7 @@
|
|||
|
||||
<div align='center'>
|
||||
|
||||
[Homepage](https://linkwarden.app) | [Getting Started](https://docs.linkwarden.app/getting-started) | [Features](https://github.com/linkwarden/linkwarden#features) | [Roadmap](https://github.com/linkwarden/linkwarden#roadmap) | [Screenshots](https://github.com/linkwarden/linkwarden#screenshots) | [Support ❤](https://github.com/linkwarden/linkwarden#support-)
|
||||
[Website](https://linkwarden.app) | [Getting Started](https://docs.linkwarden.app/getting-started) | [Features](https://github.com/linkwarden/linkwarden#features) | [Roadmap](https://github.com/linkwarden/linkwarden#roadmap) | [Screenshots](https://github.com/linkwarden/linkwarden#screenshots) | [Support ❤](https://github.com/linkwarden/linkwarden#support-)
|
||||
|
||||
</div>
|
||||
|
||||
|
@ -54,16 +54,15 @@ We highly recommend you **not** to use the old version as it is no longer mainta
|
|||
|
||||
There are _many_ upcoming features, below are only _some_ of the 100% planned ones:
|
||||
|
||||
- 🐳 Docker version.
|
||||
- 🌒 Dark mode.
|
||||
- 📦 Import/Export your data.
|
||||
- 🧩 Browser extention.
|
||||
|
||||
Also make sure to check out our [public roadmap](https://github.com/orgs/linkwarden/projects/1).
|
||||
Also make sure to check out our [public roadmap](https://github.com/orgs/linkwarden/projects/1).
|
||||
|
||||
## Docs
|
||||
|
||||
Currently, the Documentation is a bit targeted towards a more tech-savvy audience and has so much room to improve, you can find it [here](https://docs.linkwarden.app).
|
||||
For information on how to get started or to set up your own instance, please visit the [documentation](https://docs.linkwarden.app).
|
||||
|
||||
## Development
|
||||
|
||||
|
|
|
@ -6,6 +6,9 @@ import { signOut, useSession } from "next-auth/react";
|
|||
import { faPenToSquare } from "@fortawesome/free-regular-svg-icons";
|
||||
import SubmitButton from "../../SubmitButton";
|
||||
import { toast } from "react-hot-toast";
|
||||
import Link from "next/link";
|
||||
import ClickAwayHandler from "@/components/ClickAwayHandler";
|
||||
import useInitialData from "@/hooks/useInitialData";
|
||||
|
||||
type Props = {
|
||||
toggleSettingsModal: Function;
|
||||
|
@ -21,6 +24,7 @@ export default function PrivacySettings({
|
|||
const { update, data } = useSession();
|
||||
const { account, updateAccount } = useAccountStore();
|
||||
|
||||
const [importDropdown, setImportDropdown] = useState(false);
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
|
||||
const [whitelistedUsersTextbox, setWhiteListedUsersTextbox] = useState(
|
||||
|
@ -46,6 +50,38 @@ export default function PrivacySettings({
|
|||
return wordsArray;
|
||||
};
|
||||
|
||||
const postJSONFile = async (e: any) => {
|
||||
const file: File = e.target.files[0];
|
||||
|
||||
if (file) {
|
||||
var reader = new FileReader();
|
||||
reader.readAsText(file, "UTF-8");
|
||||
reader.onload = async function (e) {
|
||||
const load = toast.loading("Importing...");
|
||||
|
||||
const response = await fetch("/api/data", {
|
||||
method: "POST",
|
||||
body: e.target?.result,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
toast.dismiss(load);
|
||||
|
||||
toast.success("Imported the Bookmarks! Reloading the page...");
|
||||
|
||||
setImportDropdown(false);
|
||||
|
||||
setTimeout(() => {
|
||||
location.reload();
|
||||
}, 2000);
|
||||
};
|
||||
reader.onerror = function (e) {
|
||||
console.log("Error:", e);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
setSubmitLoader(true);
|
||||
|
||||
|
@ -115,6 +151,58 @@ export default function PrivacySettings({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<p className="text-sm text-sky-700 mb-2">Import/Export Data</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div
|
||||
onClick={() => setImportDropdown(true)}
|
||||
className="w-fit relative"
|
||||
id="import-dropdown"
|
||||
>
|
||||
<div
|
||||
id="import-dropdown"
|
||||
className="border border-slate-200 rounded-md bg-white px-2 text-center select-none cursor-pointer text-sky-900 duration-100 hover:border-sky-700"
|
||||
>
|
||||
Import From
|
||||
</div>
|
||||
{importDropdown ? (
|
||||
<ClickAwayHandler
|
||||
onClickOutside={(e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.id !== "import-dropdown") setImportDropdown(false);
|
||||
}}
|
||||
className={`absolute top-7 left-0 w-36 py-1 shadow-md border border-sky-100 bg-gray-50 rounded-md flex flex-col z-20`}
|
||||
>
|
||||
<div className="cursor-pointer rounded-md">
|
||||
<label
|
||||
htmlFor="import-file"
|
||||
title="JSON"
|
||||
className="flex items-center gap-2 py-1 px-2 hover:bg-slate-200 duration-100 cursor-pointer"
|
||||
>
|
||||
Linkwarden
|
||||
<input
|
||||
type="file"
|
||||
name="photo"
|
||||
id="import-file"
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
onChange={postJSONFile}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</ClickAwayHandler>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Link className="w-fit" href="/api/data">
|
||||
<div className="border border-slate-200 rounded-md bg-white px-2 text-center select-none cursor-pointer text-sky-900 duration-100 hover:border-sky-700">
|
||||
Export Data
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SubmitButton
|
||||
onClick={submit}
|
||||
loading={submitLoader}
|
||||
|
|
|
@ -66,7 +66,9 @@ export default function Sidebar({ className }: { className?: string }) {
|
|||
icon={faChartSimple}
|
||||
className={`w-8 h-8 drop-shadow text-sky-500 dark:text-sky-100`}
|
||||
/>
|
||||
<p className="text-sky-700 dark:text-sky-200 text-xs font-semibold">Dashboard</p>
|
||||
|
||||
<p className="text-sky-700 dark:text-sky-200 text-xs xl:text-sm font-semibold">Dashboard</p>
|
||||
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
|
@ -81,9 +83,9 @@ export default function Sidebar({ className }: { className?: string }) {
|
|||
icon={faLink}
|
||||
className={`w-8 h-8 drop-shadow text-sky-500 dark:text-sky-100`}
|
||||
/>
|
||||
<p className="text-sky-700 dark:text-sky-200 text-xs font-semibold">
|
||||
<span className="hidden xl:inline-block">All</span> Links
|
||||
</p>
|
||||
|
||||
<p className="text-sky-700 dark:text-sky-200 text-xs xl:text-sm font-semibold">Links</p>
|
||||
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
|
@ -96,8 +98,9 @@ export default function Sidebar({ className }: { className?: string }) {
|
|||
icon={faFolder}
|
||||
className={`w-8 h-8 drop-shadow text-sky-500 dark:text-sky-100`}
|
||||
/>
|
||||
<p className="text-sky-700 dark:text-sky-200 text-xs font-semibold">
|
||||
<span className="hidden xl:inline-block">All</span> Collections
|
||||
|
||||
<p className="text-sky-700 dark:text-sky-200 text-xs xl:text-sm font-semibold">
|
||||
Collections
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
version: "3.5"
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
env_file: .env
|
||||
restart: always
|
||||
volumes:
|
||||
- ./pgdata:/var/lib/postgresql/data
|
||||
linkwarden:
|
||||
env_file: .env
|
||||
platform: linux/x86_64
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/postgres
|
||||
restart: always
|
||||
build: .
|
||||
ports:
|
||||
- 3000:3000
|
||||
volumes:
|
||||
- ./data:/data/data
|
||||
depends_on:
|
||||
- postgres
|
|
@ -13,7 +13,10 @@ export default function useInitialData() {
|
|||
const { setAccount } = useAccountStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "authenticated" && data.user.isSubscriber) {
|
||||
if (
|
||||
status === "authenticated" &&
|
||||
(!process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE || data.user.isSubscriber)
|
||||
) {
|
||||
setCollections();
|
||||
setTags();
|
||||
// setLinks();
|
||||
|
|
|
@ -2,11 +2,7 @@ import { Page, chromium, devices } from "playwright";
|
|||
import { prisma } from "@/lib/api/db";
|
||||
import createFile from "@/lib/api/storage/createFile";
|
||||
|
||||
export default async function archive(
|
||||
url: string,
|
||||
collectionId: number,
|
||||
linkId: number
|
||||
) {
|
||||
export default async function archive(linkId: number, url: string) {
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext(devices["Desktop Chrome"]);
|
||||
const page = await context.newPage();
|
||||
|
@ -14,7 +10,10 @@ export default async function archive(
|
|||
try {
|
||||
await page.goto(url, { waitUntil: "domcontentloaded" });
|
||||
|
||||
await autoScroll(page);
|
||||
await page.evaluate(
|
||||
autoScroll,
|
||||
Number(process.env.AUTOSCROLL_TIMEOUT) || 30
|
||||
);
|
||||
|
||||
const linkExists = await prisma.link.findUnique({
|
||||
where: {
|
||||
|
@ -35,12 +34,12 @@ export default async function archive(
|
|||
|
||||
createFile({
|
||||
data: screenshot,
|
||||
filePath: `archives/${collectionId}/${linkId}.png`,
|
||||
filePath: `archives/${linkExists.collectionId}/${linkId}.png`,
|
||||
});
|
||||
|
||||
createFile({
|
||||
data: pdf,
|
||||
filePath: `archives/${collectionId}/${linkId}.pdf`,
|
||||
filePath: `archives/${linkExists.collectionId}/${linkId}.pdf`,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -51,29 +50,31 @@ export default async function archive(
|
|||
}
|
||||
}
|
||||
|
||||
const autoScroll = async (page: Page) => {
|
||||
await page.evaluate(async () => {
|
||||
const timeoutPromise = new Promise<void>((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error("Auto scroll took too long (more than 20 seconds)."));
|
||||
}, 20000);
|
||||
});
|
||||
|
||||
const scrollingPromise = new Promise<void>((resolve) => {
|
||||
let totalHeight = 0;
|
||||
let distance = 100;
|
||||
let scrollDown = setInterval(() => {
|
||||
let scrollHeight = document.body.scrollHeight;
|
||||
window.scrollBy(0, distance);
|
||||
totalHeight += distance;
|
||||
if (totalHeight >= scrollHeight) {
|
||||
clearInterval(scrollDown);
|
||||
window.scroll(0, 0);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
await Promise.race([scrollingPromise, timeoutPromise]);
|
||||
const autoScroll = async (AUTOSCROLL_TIMEOUT: number) => {
|
||||
const timeoutPromise = new Promise<void>((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(
|
||||
new Error(
|
||||
`Auto scroll took too long (more than ${AUTOSCROLL_TIMEOUT} seconds).`
|
||||
)
|
||||
);
|
||||
}, AUTOSCROLL_TIMEOUT * 1000);
|
||||
});
|
||||
|
||||
const scrollingPromise = new Promise<void>((resolve) => {
|
||||
let totalHeight = 0;
|
||||
let distance = 100;
|
||||
let scrollDown = setInterval(() => {
|
||||
let scrollHeight = document.body.scrollHeight;
|
||||
window.scrollBy(0, distance);
|
||||
totalHeight += distance;
|
||||
if (totalHeight >= scrollHeight) {
|
||||
clearInterval(scrollDown);
|
||||
window.scroll(0, 0);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
await Promise.race([scrollingPromise, timeoutPromise]);
|
||||
};
|
||||
|
|
|
@ -40,14 +40,6 @@ export default async function postCollection(
|
|||
name: collection.name.trim(),
|
||||
description: collection.description,
|
||||
color: collection.color,
|
||||
members: {
|
||||
create: collection.members.map((e) => ({
|
||||
user: { connect: { id: e.user.id } },
|
||||
canCreate: e.canCreate,
|
||||
canUpdate: e.canUpdate,
|
||||
canDelete: e.canDelete,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
import { prisma } from "@/lib/api/db";
|
||||
|
||||
export default async function getData(userId: number) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
collections: {
|
||||
include: {
|
||||
links: {
|
||||
include: {
|
||||
tags: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) return { response: "User not found.", status: 404 };
|
||||
|
||||
const { password, id, image, ...userData } = user;
|
||||
|
||||
return { response: userData, status: 200 };
|
||||
}
|
|
@ -0,0 +1,86 @@
|
|||
import { prisma } from "@/lib/api/db";
|
||||
import { Backup } from "@/types/global";
|
||||
import createFolder from "@/lib/api/storage/createFolder";
|
||||
|
||||
export default async function getData(userId: number, data: Backup) {
|
||||
// Import collections
|
||||
try {
|
||||
data.collections.forEach(async (e) => {
|
||||
e.name = e.name.trim();
|
||||
|
||||
const findCollection = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
collections: {
|
||||
where: {
|
||||
name: e.name,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const checkIfCollectionExists = findCollection?.collections[0];
|
||||
|
||||
let collectionId = findCollection?.collections[0]?.id;
|
||||
|
||||
if (!checkIfCollectionExists) {
|
||||
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}` });
|
||||
|
||||
collectionId = newCollection.id;
|
||||
}
|
||||
|
||||
// Import Links
|
||||
e.links.forEach(async (e) => {
|
||||
const newLink = await prisma.link.create({
|
||||
data: {
|
||||
url: e.url,
|
||||
name: e.name,
|
||||
description: e.description,
|
||||
collection: {
|
||||
connect: {
|
||||
id: collectionId,
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
connectOrCreate: e.tags.map((tag) => ({
|
||||
where: {
|
||||
name_ownerId: {
|
||||
name: tag.name.trim(),
|
||||
ownerId: userId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
name: tag.name.trim(),
|
||||
owner: {
|
||||
connect: {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
|
||||
return { response: "Success.", status: 200 };
|
||||
}
|
|
@ -5,6 +5,7 @@ export default async function getLink(userId: number, body: string) {
|
|||
const query: LinkRequestQuery = JSON.parse(decodeURIComponent(body));
|
||||
console.log(query);
|
||||
|
||||
const POSTGRES_IS_ENABLED = process.env.DATABASE_URL.startsWith("postgresql");
|
||||
// Sorting logic
|
||||
let order: any;
|
||||
if (query.sort === Sort.DateNewestFirst)
|
||||
|
@ -66,7 +67,7 @@ export default async function getLink(userId: number, body: string) {
|
|||
query.searchQuery && query.searchFilter?.name
|
||||
? query.searchQuery
|
||||
: undefined,
|
||||
mode: "insensitive",
|
||||
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -75,7 +76,7 @@ export default async function getLink(userId: number, body: string) {
|
|||
query.searchQuery && query.searchFilter?.url
|
||||
? query.searchQuery
|
||||
: undefined,
|
||||
mode: "insensitive",
|
||||
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -84,7 +85,7 @@ export default async function getLink(userId: number, body: string) {
|
|||
query.searchQuery && query.searchFilter?.description
|
||||
? query.searchQuery
|
||||
: undefined,
|
||||
mode: "insensitive",
|
||||
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -100,7 +101,9 @@ export default async function getLink(userId: number, body: string) {
|
|||
query.searchQuery && query.searchFilter?.tags
|
||||
? {
|
||||
contains: query.searchQuery,
|
||||
mode: "insensitive",
|
||||
mode: POSTGRES_IS_ENABLED
|
||||
? "insensitive"
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
OR: [
|
||||
|
@ -114,7 +117,9 @@ export default async function getLink(userId: number, body: string) {
|
|||
query.searchFilter?.tags
|
||||
? query.searchQuery
|
||||
: undefined,
|
||||
mode: "insensitive",
|
||||
mode: POSTGRES_IS_ENABLED
|
||||
? "insensitive"
|
||||
: undefined,
|
||||
},
|
||||
collection: {
|
||||
members: {
|
||||
|
|
|
@ -20,12 +20,12 @@ export default async function postLink(
|
|||
};
|
||||
}
|
||||
|
||||
link.collection.name = link.collection.name.trim();
|
||||
|
||||
if (!link.collection.name) {
|
||||
link.collection.name = "Unnamed Collection";
|
||||
}
|
||||
|
||||
link.collection.name = link.collection.name.trim();
|
||||
|
||||
if (link.collection.id) {
|
||||
const collectionIsAccessible = (await getPermission(
|
||||
userId,
|
||||
|
@ -51,7 +51,7 @@ export default async function postLink(
|
|||
? link.description
|
||||
: await getTitle(link.url);
|
||||
|
||||
const newLink: Link = await prisma.link.create({
|
||||
const newLink = await prisma.link.create({
|
||||
data: {
|
||||
url: link.url,
|
||||
name: link.name,
|
||||
|
@ -94,7 +94,7 @@ export default async function postLink(
|
|||
|
||||
createFolder({ filePath: `archives/${newLink.collectionId}` });
|
||||
|
||||
archive(newLink.url, newLink.collectionId, newLink.id);
|
||||
archive(newLink.id, newLink.url);
|
||||
|
||||
return { response: newLink, status: 200 };
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import { prisma } from "@/lib/api/db";
|
|||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||
import { Collection, Link, UsersAndCollections } from "@prisma/client";
|
||||
import getPermission from "@/lib/api/getPermission";
|
||||
import moveFile from "@/lib/api/storage/moveFile";
|
||||
|
||||
export default async function updateLink(
|
||||
link: LinkIncludingShortenedCollectionAndTags,
|
||||
|
@ -98,6 +99,18 @@ export default async function updateLink(
|
|||
},
|
||||
});
|
||||
|
||||
if (targetLink.collection.id !== link.collection.id) {
|
||||
await moveFile(
|
||||
`archives/${targetLink.collection.id}/${link.id}.pdf`,
|
||||
`archives/${link.collection.id}/${link.id}.pdf`
|
||||
);
|
||||
|
||||
await moveFile(
|
||||
`archives/${targetLink.collection.id}/${link.id}.png`,
|
||||
`archives/${link.collection.id}/${link.id}.png`
|
||||
);
|
||||
}
|
||||
|
||||
return { response: updatedLink, status: 200 };
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,14 +17,23 @@ export default async function getUser({
|
|||
id: params.lookupId,
|
||||
username: params.lookupUsername?.toLowerCase(),
|
||||
},
|
||||
include: {
|
||||
whitelistedUsers: {
|
||||
select: {
|
||||
username: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) return { response: "User not found.", status: 404 };
|
||||
|
||||
const whitelistedUsernames = user.whitelistedUsers?.map(usernames => usernames.username);
|
||||
|
||||
if (
|
||||
!isSelf &&
|
||||
user?.isPrivate &&
|
||||
!user.whitelistedUsers.includes(username.toLowerCase())
|
||||
!whitelistedUsernames.includes(username.toLowerCase())
|
||||
) {
|
||||
return { response: "This profile is private.", status: 401 };
|
||||
}
|
||||
|
@ -33,7 +42,7 @@ export default async function getUser({
|
|||
|
||||
const data = isSelf
|
||||
? // If user is requesting its own data
|
||||
lessSensitiveInfo
|
||||
{...lessSensitiveInfo, whitelistedUsers: whitelistedUsernames}
|
||||
: {
|
||||
// If user is requesting someone elses data
|
||||
id: lessSensitiveInfo.id,
|
||||
|
|
|
@ -3,7 +3,11 @@ 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 "../../updateCustomerEmail";
|
||||
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 updateUser(
|
||||
user: AccountSettings,
|
||||
|
@ -14,9 +18,14 @@ export default async function updateUser(
|
|||
isSubscriber: boolean;
|
||||
}
|
||||
) {
|
||||
if (!user.username || !user.email)
|
||||
if (emailEnabled && !user.email)
|
||||
return {
|
||||
response: "Username/Email invalid.",
|
||||
response: "Email invalid.",
|
||||
status: 400,
|
||||
};
|
||||
else if (!user.username)
|
||||
return {
|
||||
response: "Username invalid.",
|
||||
status: 400,
|
||||
};
|
||||
|
||||
|
@ -32,14 +41,20 @@ export default async function updateUser(
|
|||
const userIsTaken = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: { not: sessionUser.id },
|
||||
OR: [
|
||||
{
|
||||
username: user.username.toLowerCase(),
|
||||
},
|
||||
{
|
||||
email: user.email.toLowerCase(),
|
||||
},
|
||||
],
|
||||
OR: emailEnabled
|
||||
? [
|
||||
{
|
||||
username: user.username.toLowerCase(),
|
||||
},
|
||||
{
|
||||
email: user.email?.toLowerCase(),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
username: user.username.toLowerCase(),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -58,6 +73,8 @@ export default async function updateUser(
|
|||
try {
|
||||
const base64Data = profilePic.replace(/^data:image\/jpeg;base64,/, "");
|
||||
|
||||
createFolder({ filePath: `uploads/avatar` });
|
||||
|
||||
await createFile({
|
||||
filePath: `uploads/avatar/${sessionUser.id}.jpg`,
|
||||
data: base64Data,
|
||||
|
@ -91,29 +108,71 @@ export default async function updateUser(
|
|||
username: user.username.toLowerCase(),
|
||||
email: user.email?.toLowerCase(),
|
||||
isPrivate: user.isPrivate,
|
||||
whitelistedUsers: user.whitelistedUsers,
|
||||
password:
|
||||
user.newPassword && user.newPassword !== ""
|
||||
? newHashedPassword
|
||||
: undefined,
|
||||
},
|
||||
include: {
|
||||
whitelistedUsers: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { whitelistedUsers, password, ...userInfo } = updatedUser;
|
||||
|
||||
// If user.whitelistedUsers is not provided, we will assume the whitelistedUsers should be removed
|
||||
const newWhitelistedUsernames: string[] = user.whitelistedUsers || [];
|
||||
|
||||
// Get the current whitelisted usernames
|
||||
const currentWhitelistedUsernames: string[] = whitelistedUsers.map(
|
||||
(user) => user.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: sessionUser.id,
|
||||
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: sessionUser.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
|
||||
const PRICE_ID = process.env.PRICE_ID;
|
||||
|
||||
if (STRIPE_SECRET_KEY && PRICE_ID)
|
||||
if (STRIPE_SECRET_KEY && PRICE_ID && emailEnabled)
|
||||
await updateCustomerEmail(
|
||||
STRIPE_SECRET_KEY,
|
||||
PRICE_ID,
|
||||
sessionUser.email,
|
||||
user.email
|
||||
user.email as string
|
||||
);
|
||||
|
||||
const { password, ...userInfo } = updatedUser;
|
||||
|
||||
const response: Omit<AccountSettings, "password"> = {
|
||||
...userInfo,
|
||||
whitelistedUsers: newWhitelistedUsernames,
|
||||
profilePic: `/api/avatar/${userInfo.id}?${Date.now()}`,
|
||||
};
|
||||
|
||||
|
|
|
@ -10,9 +10,10 @@ export const prisma =
|
|||
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
|
||||
if (process.env.NODE_ENV !== "production")
|
||||
prisma.$on("query" as any, (e: any) => {
|
||||
console.log("Query: " + e.query);
|
||||
console.log("Params: " + e.params);
|
||||
console.log("\x1b[31m", `Duration: ${e.duration}ms`, "\x1b[0m"); // For benchmarking
|
||||
});
|
||||
// For benchmarking | uncomment when needed
|
||||
// if (process.env.NODE_ENV !== "production")
|
||||
// prisma.$on("query" as any, (e: any) => {
|
||||
// console.log("Query: " + e.query);
|
||||
// console.log("Params: " + e.params);
|
||||
// console.log("\x1b[31m", `Duration: ${e.duration}ms`, "\x1b[0m");
|
||||
// });
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
import fs from "fs";
|
||||
import path from "path";
|
||||
import s3Client from "./s3Client";
|
||||
import removeFile from "./removeFile";
|
||||
|
||||
export default async function moveFile(from: string, to: string) {
|
||||
if (s3Client) {
|
||||
const Bucket = process.env.BUCKET_NAME;
|
||||
|
||||
const copyParams = {
|
||||
Bucket: Bucket,
|
||||
CopySource: `/${Bucket}/${from}`,
|
||||
Key: to,
|
||||
};
|
||||
|
||||
try {
|
||||
s3Client.copyObject(copyParams, async (err: any) => {
|
||||
if (err) {
|
||||
console.error("Error copying the object:", err);
|
||||
} else {
|
||||
await removeFile({ filePath: from });
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.log("Error:", err);
|
||||
}
|
||||
} else {
|
||||
const storagePath = process.env.STORAGE_FOLDER || "data";
|
||||
|
||||
const directory = (file: string) =>
|
||||
path.join(process.cwd(), storagePath + "/" + file);
|
||||
|
||||
fs.rename(directory(from), directory(to), (err) => {
|
||||
if (err) console.log("Error copying file:", err);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -9,12 +9,14 @@ import s3Client from "./s3Client";
|
|||
import util from "util";
|
||||
|
||||
type ReturnContentTypes =
|
||||
| "text/plain"
|
||||
| "text/html"
|
||||
| "image/jpeg"
|
||||
| "image/png"
|
||||
| "application/pdf";
|
||||
|
||||
export default async function readFile({ filePath }: { filePath: string }) {
|
||||
export default async function readFile(filePath: string) {
|
||||
const isRequestingAvatar = filePath.startsWith("uploads/avatar");
|
||||
|
||||
let contentType: ReturnContentTypes;
|
||||
|
||||
if (s3Client) {
|
||||
|
@ -28,6 +30,7 @@ export default async function readFile({ filePath }: { filePath: string }) {
|
|||
| {
|
||||
file: Buffer | string;
|
||||
contentType: ReturnContentTypes;
|
||||
status: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
|
@ -38,11 +41,12 @@ export default async function readFile({ filePath }: { filePath: string }) {
|
|||
try {
|
||||
await headObjectAsync(bucketParams);
|
||||
} catch (err) {
|
||||
contentType = "text/plain";
|
||||
contentType = "text/html";
|
||||
|
||||
returnObject = {
|
||||
file: "File not found, it's possible that the file you're looking for either doesn't exist or hasn't been created yet.",
|
||||
file: isRequestingAvatar ? "File not found." : fileNotFoundTemplate,
|
||||
contentType,
|
||||
status: isRequestingAvatar ? 200 : 400,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -60,14 +64,14 @@ export default async function readFile({ filePath }: { filePath: string }) {
|
|||
// if (filePath.endsWith(".jpg"))
|
||||
contentType = "image/jpeg";
|
||||
}
|
||||
returnObject = { file: data as Buffer, contentType };
|
||||
returnObject = { file: data as Buffer, contentType, status: 200 };
|
||||
}
|
||||
|
||||
return returnObject;
|
||||
} catch (err) {
|
||||
console.log("Error:", err);
|
||||
|
||||
contentType = "text/plain";
|
||||
contentType = "text/html";
|
||||
return {
|
||||
file: "An internal occurred, please contact support.",
|
||||
contentType,
|
||||
|
@ -77,13 +81,7 @@ export default async function readFile({ filePath }: { filePath: string }) {
|
|||
const storagePath = process.env.STORAGE_FOLDER || "data";
|
||||
const creationPath = path.join(process.cwd(), storagePath + "/" + filePath);
|
||||
|
||||
const file = fs.existsSync(creationPath)
|
||||
? fs.readFileSync(creationPath)
|
||||
: "File not found, it's possible that the file you're looking for either doesn't exist or hasn't been created yet.";
|
||||
|
||||
if (file.toString().startsWith("File not found")) {
|
||||
contentType = "text/plain";
|
||||
} else if (filePath.endsWith(".pdf")) {
|
||||
if (filePath.endsWith(".pdf")) {
|
||||
contentType = "application/pdf";
|
||||
} else if (filePath.endsWith(".png")) {
|
||||
contentType = "image/png";
|
||||
|
@ -92,7 +90,16 @@ export default async function readFile({ filePath }: { filePath: string }) {
|
|||
contentType = "image/jpeg";
|
||||
}
|
||||
|
||||
return { file, contentType };
|
||||
if (!fs.existsSync(creationPath))
|
||||
return {
|
||||
file: isRequestingAvatar ? "File not found." : fileNotFoundTemplate,
|
||||
contentType: "text/html",
|
||||
status: isRequestingAvatar ? 200 : 400,
|
||||
};
|
||||
else {
|
||||
const file = fs.readFileSync(creationPath);
|
||||
return { file, contentType, status: 200 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -105,3 +112,21 @@ const streamToBuffer = (stream: any) => {
|
|||
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
};
|
||||
|
||||
const fileNotFoundTemplate = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>File not found</title>
|
||||
</head>
|
||||
<body style="margin-left: auto; margin-right: auto; max-width: 500px; padding: 1rem; font-family: sans-serif; background-color: rgb(251, 251, 251);">
|
||||
<h1>File not found</h1>
|
||||
<h2>It is possible that the file you're looking for either doesn't exist or hasn't been created yet.</h2>
|
||||
<h3>Some possible reasons are:</h3>
|
||||
<ul>
|
||||
<li>You are trying to access a file too early, before it has been fully archived.</li>
|
||||
<li>The file doesn't exist either because it encountered an error while being archived, or it simply doesn't exist.</li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>`;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export default async function avatarExists(fileUrl: string): Promise<boolean> {
|
||||
const response = await fetch(fileUrl, { method: "HEAD" });
|
||||
return !(response.headers.get("content-type") === "text/plain");
|
||||
return !(response.headers.get("content-type") === "text/html");
|
||||
}
|
||||
|
|
|
@ -31,10 +31,10 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
|
|||
.status(401)
|
||||
.json({ response: "You don't have access to this collection." });
|
||||
|
||||
const { file, contentType } = await readFile({
|
||||
filePath: `archives/${collectionId}/${linkId}`,
|
||||
});
|
||||
res.setHeader("Content-Type", contentType).status(200);
|
||||
const { file, contentType, status } = await readFile(
|
||||
`archives/${collectionId}/${linkId}`
|
||||
);
|
||||
res.setHeader("Content-Type", contentType).status(status as number);
|
||||
|
||||
return res.send(file);
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
|
|||
|
||||
if (!userId || !username)
|
||||
return res
|
||||
.setHeader("Content-Type", "text/plain")
|
||||
.setHeader("Content-Type", "text/html")
|
||||
.status(401)
|
||||
.send("You must be logged in.");
|
||||
else if (session?.user?.isSubscriber === false)
|
||||
|
@ -24,7 +24,7 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
|
|||
|
||||
if (!queryId)
|
||||
return res
|
||||
.setHeader("Content-Type", "text/plain")
|
||||
.setHeader("Content-Type", "text/html")
|
||||
.status(401)
|
||||
.send("Invalid parameters.");
|
||||
|
||||
|
@ -33,23 +33,28 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
|
|||
where: {
|
||||
id: queryId,
|
||||
},
|
||||
include: {
|
||||
whitelistedUsers: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
targetUser?.isPrivate &&
|
||||
!targetUser.whitelistedUsers.includes(username)
|
||||
) {
|
||||
const whitelistedUsernames = targetUser?.whitelistedUsers.map(
|
||||
(whitelistedUsername) => whitelistedUsername.username
|
||||
);
|
||||
|
||||
if (targetUser?.isPrivate && !whitelistedUsernames?.includes(username)) {
|
||||
return res
|
||||
.setHeader("Content-Type", "text/plain")
|
||||
.setHeader("Content-Type", "text/html")
|
||||
.send("This profile is private.");
|
||||
}
|
||||
}
|
||||
|
||||
const { file, contentType } = await readFile({
|
||||
filePath: `uploads/avatar/${queryId}.jpg`,
|
||||
});
|
||||
const { file, contentType, status } = await readFile(
|
||||
`uploads/avatar/${queryId}.jpg`
|
||||
);
|
||||
|
||||
res.setHeader("Content-Type", contentType);
|
||||
|
||||
return res.send(file);
|
||||
return res
|
||||
.setHeader("Content-Type", contentType)
|
||||
.status(status as number)
|
||||
.send(file);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import getData from "@/lib/api/controllers/data/getData";
|
||||
import postData from "@/lib/api/controllers/data/postData";
|
||||
|
||||
export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getServerSession(req, res, authOptions);
|
||||
|
||||
if (!session?.user.id) {
|
||||
return res.status(401).json({ response: "You must be logged in." });
|
||||
} else if (session?.user?.isSubscriber === false)
|
||||
res.status(401).json({
|
||||
response:
|
||||
"You are not a subscriber, feel free to reach out to us at support@linkwarden.app in case of any issues.",
|
||||
});
|
||||
|
||||
if (req.method === "GET") {
|
||||
const data = await getData(session.user.id);
|
||||
if (data.status === 200)
|
||||
return res
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setHeader("Content-Disposition", "attachment; filename=backup.json")
|
||||
.status(data.status)
|
||||
.json(data.response);
|
||||
} else if (req.method === "POST") {
|
||||
console.log(JSON.parse(req.body));
|
||||
const data = await postData(session.user.id, JSON.parse(req.body));
|
||||
return res.status(data.status).json({ response: data.response });
|
||||
}
|
||||
}
|
|
@ -15,7 +15,7 @@ export default function EmailConfirmaion() {
|
|||
<hr className="my-5" />
|
||||
|
||||
<p className="text-sm text-gray-500 ">
|
||||
If you didn't recieve anything, go to the{" "}
|
||||
If you didn't receive anything, go to the{" "}
|
||||
<Link href="/forgot" className="font-bold">
|
||||
Password Recovery
|
||||
</Link>{" "}
|
||||
|
|
|
@ -3,7 +3,7 @@ import { useState } from "react";
|
|||
import { toast } from "react-hot-toast";
|
||||
import SubmitButton from "@/components/SubmitButton";
|
||||
import { signIn } from "next-auth/react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
import CenteredForm from "@/layouts/CenteredForm";
|
||||
|
||||
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER;
|
||||
|
@ -18,6 +18,7 @@ type FormData = {
|
|||
|
||||
export default function Register() {
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const [form, setForm] = useState<FormData>({
|
||||
name: "",
|
||||
|
@ -28,7 +29,7 @@ export default function Register() {
|
|||
});
|
||||
|
||||
async function registerUser() {
|
||||
const checkHasEmptyFields = () => {
|
||||
const checkFields = () => {
|
||||
if (emailEnabled) {
|
||||
return (
|
||||
form.name !== "" &&
|
||||
|
@ -46,14 +47,7 @@ export default function Register() {
|
|||
}
|
||||
};
|
||||
|
||||
const sendConfirmation = async () => {
|
||||
await signIn("email", {
|
||||
email: form.email,
|
||||
callbackUrl: "/",
|
||||
});
|
||||
};
|
||||
|
||||
if (checkHasEmptyFields()) {
|
||||
if (checkFields()) {
|
||||
if (form.password !== form.passwordConfirmation)
|
||||
return toast.error("Passwords do not match.");
|
||||
else if (form.password.length < 8)
|
||||
|
@ -78,7 +72,12 @@ export default function Register() {
|
|||
setSubmitLoader(false);
|
||||
|
||||
if (response.ok) {
|
||||
if (form.email) await sendConfirmation();
|
||||
if (form.email && emailEnabled)
|
||||
await signIn("email", {
|
||||
email: form.email,
|
||||
callbackUrl: "/",
|
||||
});
|
||||
else if (!emailEnabled) router.push("/login");
|
||||
|
||||
toast.success("User Created!");
|
||||
} else {
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `whitelistedUsers` on the `User` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" DROP COLUMN "whitelistedUsers";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WhitelistedUser" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"username" TEXT NOT NULL DEFAULT '',
|
||||
"userId" INTEGER,
|
||||
|
||||
CONSTRAINT "WhitelistedUser_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WhitelistedUser" ADD CONSTRAINT "WhitelistedUser_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
@ -4,22 +4,22 @@ generator client {
|
|||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
userId Int
|
||||
type String
|
||||
provider String
|
||||
providerAccountId String
|
||||
refresh_token String? @db.Text
|
||||
access_token String? @db.Text
|
||||
expires_at Int?
|
||||
token_type String?
|
||||
scope String?
|
||||
id_token String? @db.Text
|
||||
session_state String?
|
||||
id String @id @default(cuid())
|
||||
userId Int
|
||||
type String
|
||||
provider String
|
||||
providerAccountId String
|
||||
refresh_token String?
|
||||
access_token String?
|
||||
expires_at Int?
|
||||
token_type String?
|
||||
scope String?
|
||||
id_token String?
|
||||
session_state String?
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
|
@ -35,29 +35,37 @@ model Session {
|
|||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
|
||||
username String? @unique
|
||||
username String? @unique
|
||||
|
||||
email String? @unique
|
||||
emailVerified DateTime?
|
||||
image String?
|
||||
email String? @unique
|
||||
emailVerified DateTime?
|
||||
image String?
|
||||
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
|
||||
password String
|
||||
collections Collection[]
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
|
||||
tags Tag[]
|
||||
password String
|
||||
collections Collection[]
|
||||
|
||||
pinnedLinks Link[]
|
||||
|
||||
collectionsJoined UsersAndCollections[]
|
||||
isPrivate Boolean @default(false)
|
||||
whitelistedUsers String[] @default([])
|
||||
createdAt DateTime @default(now())
|
||||
tags Tag[]
|
||||
|
||||
pinnedLinks Link[]
|
||||
|
||||
collectionsJoined UsersAndCollections[]
|
||||
isPrivate Boolean @default(false)
|
||||
whitelistedUsers WhitelistedUser[]
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model WhitelistedUser {
|
||||
id Int @id @default(autoincrement())
|
||||
|
||||
username String @default("")
|
||||
User User? @relation(fields: [userId], references: [id])
|
||||
userId Int?
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
|
@ -69,55 +77,55 @@ model VerificationToken {
|
|||
}
|
||||
|
||||
model Collection {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
description String @default("")
|
||||
color String @default("#0ea5e9")
|
||||
isPublic Boolean @default(false)
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
description String @default("")
|
||||
color String @default("#0ea5e9")
|
||||
isPublic Boolean @default(false)
|
||||
|
||||
|
||||
owner User @relation(fields: [ownerId], references: [id])
|
||||
ownerId Int
|
||||
members UsersAndCollections[]
|
||||
links Link[]
|
||||
createdAt DateTime @default(now())
|
||||
owner User @relation(fields: [ownerId], references: [id])
|
||||
ownerId Int
|
||||
members UsersAndCollections[]
|
||||
links Link[]
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([name, ownerId])
|
||||
}
|
||||
|
||||
model UsersAndCollections {
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
|
||||
collection Collection @relation(fields: [collectionId], references: [id])
|
||||
collection Collection @relation(fields: [collectionId], references: [id])
|
||||
collectionId Int
|
||||
|
||||
canCreate Boolean
|
||||
canUpdate Boolean
|
||||
canDelete Boolean
|
||||
canCreate Boolean
|
||||
canUpdate Boolean
|
||||
canDelete Boolean
|
||||
|
||||
@@id([userId, collectionId])
|
||||
}
|
||||
|
||||
model Link {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
url String
|
||||
description String @default("")
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
url String
|
||||
description String @default("")
|
||||
|
||||
pinnedBy User[]
|
||||
pinnedBy User[]
|
||||
|
||||
collection Collection @relation(fields: [collectionId], references: [id])
|
||||
collectionId Int
|
||||
tags Tag[]
|
||||
createdAt DateTime @default(now())
|
||||
collection Collection @relation(fields: [collectionId], references: [id])
|
||||
collectionId Int
|
||||
tags Tag[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model Tag {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
links Link[]
|
||||
owner User @relation(fields: [ownerId], references: [id])
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
links Link[]
|
||||
owner User @relation(fields: [ownerId], references: [id])
|
||||
ownerId Int
|
||||
|
||||
@@unique([name, ownerId])
|
||||
|
|
|
@ -6,6 +6,7 @@ declare global {
|
|||
NEXTAUTH_URL: string;
|
||||
PAGINATION_TAKE_COUNT?: string;
|
||||
STORAGE_FOLDER?: string;
|
||||
AUTOSCROLL_TIMEOUT?: string;
|
||||
|
||||
SPACES_KEY?: string;
|
||||
SPACES_SECRET?: string;
|
||||
|
|
|
@ -36,6 +36,7 @@ export interface CollectionIncludingMembersAndLinkCount
|
|||
export interface AccountSettings extends User {
|
||||
profilePic: string;
|
||||
newPassword?: string;
|
||||
whitelistedUsers: string[];
|
||||
}
|
||||
|
||||
interface LinksIncludingTags extends Link {
|
||||
|
@ -76,3 +77,11 @@ export type PublicLinkRequestQuery = {
|
|||
cursor?: number;
|
||||
collectionId: number;
|
||||
};
|
||||
|
||||
interface CollectionIncludingLinks extends Collection {
|
||||
links: LinksIncludingTags[];
|
||||
}
|
||||
|
||||
export interface Backup extends Omit<User, "password" | "id" | "image"> {
|
||||
collections: CollectionIncludingLinks[];
|
||||
}
|
||||
|
|
Ŝarĝante…
Reference in New Issue