♻️ Migrate from prisma to drizzle (#1434)

* ♻️ Migrate from prisma to drizzle
* 🐛 Build issue with CalendarTile
* 🚧 Temporary solution for docker container
* 🐛 Drizzle not using DATABASE_URL
* ♻️ Address pull request feedback
* 🐛 Remove console log of env variables
* 🐛 Some unit tests not working
* 🐋 Revert docker tool changes
* 🐛 Issue with board slug page for logged in users

---------

Co-authored-by: Thomas Camlong <thomascamlong@gmail.com>
This commit is contained in:
Meier Lukas
2023-10-08 12:10:48 +02:00
committed by GitHub
parent 4945725702
commit 1d50e2ce9a
34 changed files with 3274 additions and 1507 deletions

View File

@@ -9,8 +9,7 @@
# When adding additional environment variables, the schema in "/src/env.js" # When adding additional environment variables, the schema in "/src/env.js"
# should be updated accordingly. # should be updated accordingly.
# Prisma # Database
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
DATABASE_URL="file:../database/db.sqlite" DATABASE_URL="file:../database/db.sqlite"
# Next Auth # Next Auth

3
.gitignore vendored
View File

@@ -58,8 +58,9 @@ public/locales/*
!public/locales/en !public/locales/en
#database #database
prisma/db.sqlite sqlite.db
database/*.sqlite database/*.sqlite
WILL_BE_OVERWRITTEN.sqlite
# IDE # IDE
.idea/* .idea/*

View File

@@ -10,24 +10,31 @@ ENV NODE_OPTIONS '--no-experimental-fetch'
COPY next.config.js ./ COPY next.config.js ./
COPY public ./public COPY public ./public
COPY package.json ./package.json COPY package.json ./temp_package.json
COPY yarn.lock ./yarn.lock COPY yarn.lock ./temp_yarn.lock
# Automatically leverage output traces to reduce image size # Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing # https://nextjs.org/docs/advanced-features/output-file-tracing
COPY .next/standalone ./ COPY .next/standalone ./
COPY .next/static ./.next/static COPY .next/static ./.next/static
COPY prisma/schema.prisma prisma/schema.prisma
COPY ./scripts/run.sh ./scripts/run.sh COPY ./scripts/run.sh ./scripts/run.sh
COPY ./drizzle ./drizzle
RUN mkdir database
COPY ./src/migrate.ts ./src/migrate.ts
# Install dependencies # Install dependencies
RUN apt-get update -y && apt-get install -y openssl RUN apt-get update -y && apt-get install -y openssl
RUN yarn global add prisma
# Required for migration
RUN cp -r node_modules node_modules_cache
RUN rm -rf node_modules
RUN rm package.json
RUN yarn add typescript ts-node dotenv drizzle-orm@0.28.6 better-sqlite3@8.6.0 @types/better-sqlite3
# Expose the default application port # Expose the default application port
EXPOSE $PORT EXPOSE $PORT
ENV PORT=${PORT} ENV PORT=${PORT}
ENV DATABASE_URL "file:../database/db.sqlite" ENV DATABASE_URL "file:./database/db.sqlite"
ENV NEXTAUTH_URL "http://localhost:3000" ENV NEXTAUTH_URL "http://localhost:3000"
ENV PORT 7575 ENV PORT 7575
ENV NEXTAUTH_SECRET NOT_IN_USE_BECAUSE_JWTS_ARE_UNUSED ENV NEXTAUTH_SECRET NOT_IN_USE_BECAUSE_JWTS_ARE_UNUSED

0
database/.gitkeep Normal file
View File

11
drizzle.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import 'dotenv';
import { type Config } from 'drizzle-kit';
export default {
schema: './src/server/db/schema.ts',
driver: 'better-sqlite',
out: './drizzle',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
} satisfies Config;

View File

@@ -0,0 +1,69 @@
CREATE TABLE `account` (
`userId` text NOT NULL,
`type` text NOT NULL,
`provider` text NOT NULL,
`providerAccountId` text NOT NULL,
`refresh_token` text,
`access_token` text,
`expires_at` integer,
`token_type` text,
`scope` text,
`id_token` text,
`session_state` text,
PRIMARY KEY(`provider`, `providerAccountId`),
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `invite` (
`id` text PRIMARY KEY NOT NULL,
`token` text NOT NULL,
`expires` integer NOT NULL,
`created_by_id` text NOT NULL,
FOREIGN KEY (`created_by_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `session` (
`sessionToken` text PRIMARY KEY NOT NULL,
`userId` text NOT NULL,
`expires` integer NOT NULL,
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `user_setting` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`color_scheme` text DEFAULT 'environment' NOT NULL,
`language` text DEFAULT 'en' NOT NULL,
`default_board` text DEFAULT 'default' NOT NULL,
`first_day_of_week` text DEFAULT 'monday' NOT NULL,
`search_template` text DEFAULT 'https://google.com/search?q=%s' NOT NULL,
`open_search_in_new_tab` integer DEFAULT true NOT NULL,
`disable_ping_pulse` integer DEFAULT false NOT NULL,
`replace_ping_with_icons` integer DEFAULT false NOT NULL,
`use_debug_language` integer DEFAULT false NOT NULL,
`auto_focus_search` integer DEFAULT false NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `user` (
`id` text PRIMARY KEY NOT NULL,
`name` text,
`email` text,
`emailVerified` integer,
`image` text,
`password` text,
`salt` text,
`is_admin` integer DEFAULT false NOT NULL,
`is_owner` integer DEFAULT false NOT NULL
);
--> statement-breakpoint
CREATE TABLE `verificationToken` (
`identifier` text NOT NULL,
`token` text NOT NULL,
`expires` integer NOT NULL,
PRIMARY KEY(`identifier`, `token`)
);
--> statement-breakpoint
CREATE INDEX `userId_idx` ON `account` (`userId`);--> statement-breakpoint
CREATE UNIQUE INDEX `invite_token_unique` ON `invite` (`token`);--> statement-breakpoint
CREATE INDEX `user_id_idx` ON `session` (`userId`);

View File

@@ -0,0 +1,468 @@
{
"version": "5",
"dialect": "sqlite",
"id": "32c1bc91-e69f-4e1d-b53c-9c43f2e6c9d3",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"account": {
"name": "account",
"columns": {
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"providerAccountId": {
"name": "providerAccountId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"token_type": {
"name": "token_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"session_state": {
"name": "session_state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"userId_idx": {
"name": "userId_idx",
"columns": [
"userId"
],
"isUnique": false
}
},
"foreignKeys": {
"account_userId_user_id_fk": {
"name": "account_userId_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"account_provider_providerAccountId_pk": {
"columns": [
"provider",
"providerAccountId"
]
}
},
"uniqueConstraints": {}
},
"invite": {
"name": "invite",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_by_id": {
"name": "created_by_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"invite_token_unique": {
"name": "invite_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {
"invite_created_by_id_user_id_fk": {
"name": "invite_created_by_id_user_id_fk",
"tableFrom": "invite",
"tableTo": "user",
"columnsFrom": [
"created_by_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"session": {
"name": "session",
"columns": {
"sessionToken": {
"name": "sessionToken",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"user_id_idx": {
"name": "user_id_idx",
"columns": [
"userId"
],
"isUnique": false
}
},
"foreignKeys": {
"session_userId_user_id_fk": {
"name": "session_userId_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"user_setting": {
"name": "user_setting",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"color_scheme": {
"name": "color_scheme",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'environment'"
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'en'"
},
"default_board": {
"name": "default_board",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'default'"
},
"first_day_of_week": {
"name": "first_day_of_week",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'monday'"
},
"search_template": {
"name": "search_template",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'https://google.com/search?q=%s'"
},
"open_search_in_new_tab": {
"name": "open_search_in_new_tab",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"disable_ping_pulse": {
"name": "disable_ping_pulse",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"replace_ping_with_icons": {
"name": "replace_ping_with_icons",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"use_debug_language": {
"name": "use_debug_language",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"auto_focus_search": {
"name": "auto_focus_search",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {},
"foreignKeys": {
"user_setting_user_id_user_id_fk": {
"name": "user_setting_user_id_user_id_fk",
"tableFrom": "user_setting",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"emailVerified": {
"name": "emailVerified",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"salt": {
"name": "salt",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_admin": {
"name": "is_admin",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"is_owner": {
"name": "is_owner",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"verificationToken": {
"name": "verificationToken",
"columns": {
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"verificationToken_identifier_token_pk": {
"columns": [
"identifier",
"token"
]
}
},
"uniqueConstraints": {}
}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
}
}

View File

@@ -0,0 +1,13 @@
{
"version": "5",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "5",
"when": 1695874816934,
"tag": "0000_supreme_the_captain",
"breakpoints": true
}
]
}

1
next-env.d.ts vendored
View File

@@ -1,6 +1,5 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information. // see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@@ -18,15 +18,16 @@
"lint": "next lint", "lint": "next lint",
"prettier:check": "prettier --check \"**/*.{ts,tsx}\"", "prettier:check": "prettier --check \"**/*.{ts,tsx}\"",
"prettier:write": "prettier --write \"**/*.{ts,tsx}\"", "prettier:write": "prettier --write \"**/*.{ts,tsx}\"",
"test": "vitest", "test": "SKIP_ENV_VALIDATION=1 vitest",
"test:ui": "vitest --ui", "test:ui": "SKIP_ENV_VALIDATION=1 vitest --ui",
"test:run": "vitest run", "test:run": "SKIP_ENV_VALIDATION=1 vitest run",
"test:coverage": "vitest run --coverage", "test:coverage": "SKIP_ENV_VALIDATION=1 vitest run --coverage",
"docker:build": "turbo build && docker build . -t homarr:local-dev", "docker:build": "turbo build && docker build . -t homarr:local-dev",
"docker:start": "docker run -p 7575:7575 --name homarr-development homarr:local-dev", "docker:start": "docker run -p 7575:7575 --name homarr-development homarr:local-dev",
"postinstall": "prisma generate" "db:migrate": "ts-node src/migrate.ts"
}, },
"dependencies": { "dependencies": {
"@auth/drizzle-adapter": "^0.3.2",
"@ctrl/deluge": "^4.1.0", "@ctrl/deluge": "^4.1.0",
"@ctrl/qbittorrent": "^6.0.0", "@ctrl/qbittorrent": "^6.0.0",
"@ctrl/shared-torrent": "^4.1.1", "@ctrl/shared-torrent": "^4.1.1",
@@ -44,10 +45,8 @@
"@mantine/notifications": "^6.0.0", "@mantine/notifications": "^6.0.0",
"@mantine/prism": "^6.0.19", "@mantine/prism": "^6.0.19",
"@mantine/tiptap": "^6.0.17", "@mantine/tiptap": "^6.0.17",
"@next-auth/prisma-adapter": "^1.0.7",
"@nivo/core": "^0.83.0", "@nivo/core": "^0.83.0",
"@nivo/line": "^0.83.0", "@nivo/line": "^0.83.0",
"@prisma/client": "^5.0.0",
"@react-native-async-storage/async-storage": "^1.18.1", "@react-native-async-storage/async-storage": "^1.18.1",
"@t3-oss/env-nextjs": "^0.6.0", "@t3-oss/env-nextjs": "^0.6.0",
"@tabler/icons-react": "^2.20.0", "@tabler/icons-react": "^2.20.0",
@@ -60,20 +59,24 @@
"@tiptap/pm": "^2.0.4", "@tiptap/pm": "^2.0.4",
"@tiptap/react": "^2.0.4", "@tiptap/react": "^2.0.4",
"@tiptap/starter-kit": "^2.0.4", "@tiptap/starter-kit": "^2.0.4",
"@trpc/client": "^10.29.1", "@trpc/client": "^10.37.1",
"@trpc/next": "^10.29.1", "@trpc/next": "^10.37.1",
"@trpc/react-query": "^10.29.1", "@trpc/react-query": "^10.37.1",
"@trpc/server": "^10.29.1", "@trpc/server": "^10.37.1",
"@types/bcryptjs": "^2.4.2", "@types/bcryptjs": "^2.4.2",
"@vitejs/plugin-react": "^4.0.0", "@vitejs/plugin-react": "^4.0.0",
"axios": "^1.0.0", "axios": "^1.0.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"better-sqlite3": "^8.6.0",
"browser-geo-tz": "^0.0.4", "browser-geo-tz": "^0.0.4",
"consola": "^3.0.0", "consola": "^3.0.0",
"cookies": "^0.8.0", "cookies": "^0.8.0",
"cookies-next": "^2.1.1", "cookies-next": "^2.1.1",
"dayjs": "^1.11.7", "dayjs": "^1.11.7",
"dockerode": "^3.3.2", "dockerode": "^3.3.2",
"dotenv": "^16.3.1",
"drizzle-kit": "^0.19.13",
"drizzle-orm": "^0.28.6",
"fily-publish-gridstack": "^0.0.13", "fily-publish-gridstack": "^0.0.13",
"flag-icons": "^6.9.2", "flag-icons": "^6.9.2",
"framer-motion": "^10.0.0", "framer-motion": "^10.0.0",
@@ -86,10 +89,9 @@
"moment": "^2.29.4", "moment": "^2.29.4",
"moment-timezone": "^0.5.43", "moment-timezone": "^0.5.43",
"next": "13.4.12", "next": "13.4.12",
"next-auth": "^4.22.3", "next-auth": "^4.23.0",
"next-i18next": "^14.0.0", "next-i18next": "^14.0.0",
"nzbget-api": "^0.0.3", "nzbget-api": "^0.0.3",
"prisma": "^5.0.0",
"prismjs": "^1.29.0", "prismjs": "^1.29.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
@@ -110,6 +112,7 @@
"@testing-library/jest-dom": "^5.16.5", "@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^14.0.0", "@testing-library/react": "^14.0.0",
"@trivago/prettier-plugin-sort-imports": "^4.2.0", "@trivago/prettier-plugin-sort-imports": "^4.2.0",
"@types/better-sqlite3": "^7.6.5",
"@types/cookies": "^0.7.7", "@types/cookies": "^0.7.7",
"@types/dockerode": "^3.3.9", "@types/dockerode": "^3.3.9",
"@types/node": "18.17.8", "@types/node": "18.17.8",
@@ -135,6 +138,7 @@
"prettier": "^3.0.0", "prettier": "^3.0.0",
"sass": "^1.56.1", "sass": "^1.56.1",
"ts-node": "latest", "ts-node": "latest",
"ts-node-esm": "^0.0.6",
"turbo": "^1.10.12", "turbo": "^1.10.12",
"typescript": "^5.1.0", "typescript": "^5.1.0",
"video.js": "^8.0.3", "video.js": "^8.0.3",

View File

@@ -1,93 +0,0 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "linux-musl-openssl-3.0.x", "linux-musl-arm64-openssl-3.0.x", "debian-openssl-3.0.x"]
}
datasource db {
provider = "sqlite"
// NOTE: When using mysql or sqlserver, uncomment the @db.Text annotations in model Account below
// Further reading:
// https://next-auth.js.org/adapters/prisma#create-the-prisma-schema
// https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#string
url = env("DATABASE_URL")
}
// Necessary for Next auth
model Account {
id String @id @default(cuid())
userId String
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?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
password String?
salt String?
isAdmin Boolean @default(false)
isOwner Boolean @default(false)
accounts Account[]
sessions Session[]
settings UserSettings?
createdInvites Invite[]
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
model Invite {
id String @id @default(cuid())
token String @unique
expires DateTime
createdById String
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
}
model UserSettings {
id String @id @default(cuid())
userId String
colorScheme String @default("environment") // environment, light, dark
language String @default("en")
defaultBoard String @default("default")
firstDayOfWeek String @default("monday") // monday, saturnday, sunday
searchTemplate String @default("https://google.com/search?q=%s")
openSearchInNewTab Boolean @default(true)
disablePingPulse Boolean @default(false)
replacePingWithIcons Boolean @default(false)
useDebugLanguage Boolean @default(false)
autoFocusSearch Boolean @default(false)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId])
}

View File

@@ -3,8 +3,20 @@
echo "Exporting hostname..." echo "Exporting hostname..."
export NEXTAUTH_URL_INTERNAL="http://$HOSTNAME:7575" export NEXTAUTH_URL_INTERNAL="http://$HOSTNAME:7575"
echo "Pushing database changes..." echo "Migrating database..."
prisma db push --skip-generate yarn ts-node src/migrate.ts & PID=$!
# Wait for migration to finish
wait $PID
echo "Reverting to production node_modules..."
# Copy specific sqlite3 binary to node_modules
cp /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/node_modules_cache/better-sqlite3/build/Release/better_sqlite3.node
# Remove node_modules and copy cached node_modules
rm -r /app/node_modules
cp -r /app/node_modules_cache /app/node_modules
cp ./temp_package.json package.json
cp ./temp_yarn.lock yarn.lock
echo "Starting production server..." echo "Starting production server..."
node /app/server.js node /app/server.js

View File

@@ -1,24 +1,24 @@
import { Box, Text } from "@mantine/core"; import { Box, Text } from '@mantine/core';
import { IconCheck, IconX } from "@tabler/icons-react"; import { IconCheck, IconX } from '@tabler/icons-react';
import { useTranslation } from "react-i18next"; import { useTranslation } from 'next-i18next';
import { minPasswordLength } from "~/validations/user"; import { minPasswordLength } from '~/validations/user';
export const PasswordRequirement = ({ meets, label }: { meets: boolean; label: string }) => { export const PasswordRequirement = ({ meets, label }: { meets: boolean; label: string }) => {
const { t } = useTranslation('password-requirements'); const { t } = useTranslation('password-requirements');
return ( return (
<Text <Text
color={meets ? 'teal' : 'red'} color={meets ? 'teal' : 'red'}
sx={{ display: 'flex', alignItems: 'center' }} sx={{ display: 'flex', alignItems: 'center' }}
mt={7} mt={7}
size="sm" size="sm"
> >
{meets ? <IconCheck size="0.9rem" /> : <IconX size="0.9rem" />}{' '} {meets ? <IconCheck size="0.9rem" /> : <IconX size="0.9rem" />}
<Box ml={10}> <Box ml={10}>
{t(`${label}`, { {t(`${label}`, {
count: minPasswordLength, count: minPasswordLength,
})} })}
</Box> </Box>
</Text> </Text>
); );
}; };

View File

@@ -1,7 +1,11 @@
const { z } = require('zod'); const { z } = require('zod');
const { createEnv } = require('@t3-oss/env-nextjs'); const { createEnv } = require('@t3-oss/env-nextjs');
const portSchema = z.string().regex(/\d*/).transform((value) => value === undefined ? undefined : Number(value)).optional(); const portSchema = z
.string()
.regex(/\d*/)
.transform((value) => (value === undefined ? undefined : Number(value)))
.optional();
const envSchema = z.enum(['development', 'test', 'production']); const envSchema = z.enum(['development', 'test', 'production']);
const env = createEnv({ const env = createEnv({
@@ -22,7 +26,7 @@ const env = createEnv({
), ),
DOCKER_HOST: z.string().optional(), DOCKER_HOST: z.string().optional(),
DOCKER_PORT: portSchema, DOCKER_PORT: portSchema,
HOSTNAME: z.string().optional() HOSTNAME: z.string().optional(),
}, },
/** /**
@@ -57,8 +61,9 @@ const env = createEnv({
NEXT_PUBLIC_DEFAULT_COLOR_SCHEME: process.env.DEFAULT_COLOR_SCHEME, NEXT_PUBLIC_DEFAULT_COLOR_SCHEME: process.env.DEFAULT_COLOR_SCHEME,
NEXT_PUBLIC_PORT: process.env.PORT, NEXT_PUBLIC_PORT: process.env.PORT,
NEXT_PUBLIC_NODE_ENV: process.env.NODE_ENV, NEXT_PUBLIC_NODE_ENV: process.env.NODE_ENV,
HOSTNAME: process.env.HOSTNAME HOSTNAME: process.env.HOSTNAME,
}, },
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
}); });
module.exports = { module.exports = {

18
src/migrate.ts Normal file
View File

@@ -0,0 +1,18 @@
// This file is used to migrate the database to the current version
// It is run when the docker container starts
import Database from 'better-sqlite3';
import dotenv from 'dotenv';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
dotenv.config({ path: __dirname + '/../.env' });
const sqlite = new Database(process.env.DATABASE_URL!.replace('file:', ''));
const db = drizzle(sqlite);
const migrateDatabase = async () => {
await migrate(db, { migrationsFolder: './drizzle' });
};
migrateDatabase();

View File

@@ -12,6 +12,7 @@ import {
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { showNotification, updateNotification } from '@mantine/notifications'; import { showNotification, updateNotification } from '@mantine/notifications';
import { IconCheck, IconX } from '@tabler/icons-react'; import { IconCheck, IconX } from '@tabler/icons-react';
import { and, eq } from 'drizzle-orm';
import { GetServerSideProps } from 'next'; import { GetServerSideProps } from 'next';
import { signIn } from 'next-auth/react'; import { signIn } from 'next-auth/react';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
@@ -20,10 +21,11 @@ import { useRouter } from 'next/router';
import { useState } from 'react'; import { useState } from 'react';
import { z } from 'zod'; import { z } from 'zod';
import { PasswordRequirements } from '~/components/Password/password-requirements'; import { PasswordRequirements } from '~/components/Password/password-requirements';
import { FloatingBackground } from '~/components/layout/Background/FloatingBackground';
import { ThemeSchemeToggle } from '~/components/ThemeSchemeToggle/ThemeSchemeToggle'; import { ThemeSchemeToggle } from '~/components/ThemeSchemeToggle/ThemeSchemeToggle';
import { FloatingBackground } from '~/components/layout/Background/FloatingBackground';
import { getServerAuthSession } from '~/server/auth'; import { getServerAuthSession } from '~/server/auth';
import { prisma } from '~/server/db'; import { db } from '~/server/db';
import { invites } from '~/server/db/schema';
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
import { api } from '~/utils/api'; import { api } from '~/utils/api';
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver'; import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
@@ -194,14 +196,14 @@ export const getServerSideProps: GetServerSideProps = async ({
}; };
} }
const token = await prisma.invite.findUnique({ const dbInvite = await db.query.invites.findFirst({
where: { where: and(
id: routeParams.data.inviteId, eq(invites.id, routeParams.data.inviteId),
token: queryParams.data.token, eq(invites.token, queryParams.data.token)
}, ),
}); });
if (!token || token.expires < new Date()) { if (!dbInvite || dbInvite.expires < new Date()) {
return { return {
notFound: true, notFound: true,
}; };

View File

@@ -64,7 +64,7 @@ export const getServerSideProps: GetServerSideProps<BoardGetServerSideProps> = a
const result = checkForSessionOrAskForLogin( const result = checkForSessionOrAskForLogin(
ctx, ctx,
session, session,
() => config.settings.access.allowGuests || !session?.user () => config.settings.access.allowGuests || !!session?.user
); );
if (result) { if (result) {
return result; return result;

View File

@@ -1,3 +1,4 @@
import { eq } from 'drizzle-orm';
import { GetServerSideProps, InferGetServerSidePropsType } from 'next'; import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
import { SSRConfig } from 'next-i18next'; import { SSRConfig } from 'next-i18next';
import { Dashboard } from '~/components/Dashboard/Dashboard'; import { Dashboard } from '~/components/Dashboard/Dashboard';
@@ -5,7 +6,9 @@ import { BoardLayout } from '~/components/layout/Templates/BoardLayout';
import { useInitConfig } from '~/config/init'; import { useInitConfig } from '~/config/init';
import { env } from '~/env'; import { env } from '~/env';
import { getServerAuthSession } from '~/server/auth'; import { getServerAuthSession } from '~/server/auth';
import { prisma } from '~/server/db'; import { db } from '~/server/db';
import { getDefaultBoardAsync } from '~/server/db/queries/userSettings';
import { userSettings } from '~/server/db/schema';
import { getFrontendConfig } from '~/tools/config/getFrontendConfig'; import { getFrontendConfig } from '~/tools/config/getFrontendConfig';
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
import { boardNamespaces } from '~/tools/server/translation-namespaces'; import { boardNamespaces } from '~/tools/server/translation-namespaces';
@@ -32,11 +35,7 @@ type BoardGetServerSideProps = {
export const getServerSideProps: GetServerSideProps<BoardGetServerSideProps> = async (ctx) => { export const getServerSideProps: GetServerSideProps<BoardGetServerSideProps> = async (ctx) => {
const session = await getServerAuthSession(ctx); const session = await getServerAuthSession(ctx);
const currentUserSettings = await prisma.userSettings.findFirst({ const boardName = await getDefaultBoardAsync(session?.user?.id, 'default');
where: {
userId: session?.user?.id,
},
});
const translations = await getServerSideTranslations( const translations = await getServerSideTranslations(
boardNamespaces, boardNamespaces,
@@ -44,7 +43,6 @@ export const getServerSideProps: GetServerSideProps<BoardGetServerSideProps> = a
ctx.req, ctx.req,
ctx.res ctx.res
); );
const boardName = currentUserSettings?.defaultBoard ?? 'default';
const config = await getFrontendConfig(boardName); const config = await getFrontendConfig(boardName);
if (!config.settings.access.allowGuests && !session?.user) { if (!config.settings.access.allowGuests && !session?.user) {
@@ -54,7 +52,7 @@ export const getServerSideProps: GetServerSideProps<BoardGetServerSideProps> = a
primaryColor: config.settings.customization.colors.primary, primaryColor: config.settings.customization.colors.primary,
secondaryColor: config.settings.customization.colors.secondary, secondaryColor: config.settings.customization.colors.secondary,
primaryShade: config.settings.customization.colors.shade, primaryShade: config.settings.customization.colors.shade,
} },
}; };
} }

View File

@@ -10,7 +10,6 @@ import ContainerTable from '~/components/Manage/Tools/Docker/ContainerTable';
import { ManageLayout } from '~/components/layout/Templates/ManageLayout'; import { ManageLayout } from '~/components/layout/Templates/ManageLayout';
import { dockerRouter } from '~/server/api/routers/docker/router'; import { dockerRouter } from '~/server/api/routers/docker/router';
import { getServerAuthSession } from '~/server/auth'; import { getServerAuthSession } from '~/server/auth';
import { prisma } from '~/server/db';
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
import { boardNamespaces } from '~/tools/server/translation-namespaces'; import { boardNamespaces } from '~/tools/server/translation-namespaces';
import { api } from '~/utils/api'; import { api } from '~/utils/api';
@@ -66,7 +65,6 @@ export const getServerSideProps: GetServerSideProps = async ({ locale, req, res
const caller = dockerRouter.createCaller({ const caller = dockerRouter.createCaller({
session: session, session: session,
cookies: req.cookies, cookies: req.cookies,
prisma: prisma,
}); });
const translations = await getServerSideTranslations( const translations = await getServerSideTranslations(

View File

@@ -148,7 +148,7 @@ const ManageUsersPage = () => {
</tr> </tr>
))} ))}
{debouncedSearch && debouncedSearch.length > 0 && ( {debouncedSearch && debouncedSearch.length > 0 && data.countPages === 0 && (
<tr> <tr>
<td colSpan={1}> <td colSpan={1}>
<Box p={15}> <Box p={15}>

View File

@@ -7,7 +7,8 @@ import Head from 'next/head';
import { OnboardingSteps } from '~/components/Onboarding/onboarding-steps'; import { OnboardingSteps } from '~/components/Onboarding/onboarding-steps';
import { ThemeSchemeToggle } from '~/components/ThemeSchemeToggle/ThemeSchemeToggle'; import { ThemeSchemeToggle } from '~/components/ThemeSchemeToggle/ThemeSchemeToggle';
import { FloatingBackground } from '~/components/layout/Background/FloatingBackground'; import { FloatingBackground } from '~/components/layout/Background/FloatingBackground';
import { prisma } from '~/server/db'; import { db } from '~/server/db';
import { getTotalUserCountAsync } from '~/server/db/queries/user';
import { getConfig } from '~/tools/config/getConfig'; import { getConfig } from '~/tools/config/getConfig';
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
@@ -32,11 +33,7 @@ export default function OnboardPage({
<ThemeSchemeToggle pos="absolute" top={20} right={20} variant="default" /> <ThemeSchemeToggle pos="absolute" top={20} right={20} variant="default" />
<Stack h="100dvh" bg={background} spacing={0}> <Stack h="100dvh" bg={background} spacing={0}>
<Center <Center bg={fn.linearGradient(145, colors.red[7], colors.red[5])} mih={150} h={150}>
bg={fn.linearGradient(145, colors.red[7], colors.red[5])}
mih={150}
h={150}
>
<Center bg={background} w={100} h={100} style={{ borderRadius: 64 }}> <Center bg={background} w={100} h={100} style={{ borderRadius: 64 }}>
<Image width={70} src="/imgs/logo/logo-color.svg" alt="Homarr Logo" /> <Image width={70} src="/imgs/logo/logo-color.svg" alt="Homarr Logo" />
</Center> </Center>
@@ -72,7 +69,7 @@ export default function OnboardPage({
} }
export const getServerSideProps: GetServerSideProps = async (ctx) => { export const getServerSideProps: GetServerSideProps = async (ctx) => {
const userCount = await prisma.user.count(); const userCount = await getTotalUserCountAsync();
if (userCount >= 1) { if (userCount >= 1) {
return { return {
notFound: true, notFound: true,
@@ -83,7 +80,12 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => {
const configs = files.map((file) => getConfig(file)); const configs = files.map((file) => getConfig(file));
const configSchemaVersions = configs.map((config) => config.schemaVersion); const configSchemaVersions = configs.map((config) => config.schemaVersion);
const translations = await getServerSideTranslations(['password-requirements'], ctx.locale, ctx.req, ctx.res); const translations = await getServerSideTranslations(
['password-requirements'],
ctx.locale,
ctx.req,
ctx.res
);
return { return {
props: { props: {

View File

@@ -1,6 +1,7 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import fs from 'fs'; import fs from 'fs';
import { z } from 'zod'; import { z } from 'zod';
import { getDefaultBoardAsync } from '~/server/db/queries/userSettings';
import { configExists } from '~/tools/config/configExists'; import { configExists } from '~/tools/config/configExists';
import { getConfig } from '~/tools/config/getConfig'; import { getConfig } from '~/tools/config/getConfig';
import { getFrontendConfig } from '~/tools/config/getFrontendConfig'; import { getFrontendConfig } from '~/tools/config/getFrontendConfig';
@@ -13,11 +14,7 @@ export const boardRouter = createTRPCRouter({
all: protectedProcedure.query(async ({ ctx }) => { all: protectedProcedure.query(async ({ ctx }) => {
const files = fs.readdirSync('./data/configs').filter((file) => file.endsWith('.json')); const files = fs.readdirSync('./data/configs').filter((file) => file.endsWith('.json'));
const userSettings = await ctx.prisma.userSettings.findUniqueOrThrow({ const defaultBoard = await getDefaultBoardAsync(ctx.session.user.id, 'default');
where: {
userId: ctx.session?.user.id,
},
});
return await Promise.all( return await Promise.all(
files.map(async (file) => { files.map(async (file) => {
@@ -31,7 +28,7 @@ export const boardRouter = createTRPCRouter({
countApps: countApps, countApps: countApps,
countWidgets: config.widgets.length, countWidgets: config.widgets.length,
countCategories: config.categories.length, countCategories: config.categories.length,
isDefaultForUser: name === userSettings.defaultBoard, isDefaultForUser: name === defaultBoard,
}; };
}) })
); );

View File

@@ -1,6 +1,9 @@
import { randomBytes } from 'crypto'; import { randomBytes, randomUUID } from 'crypto';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { eq, sql } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { db } from '~/server/db';
import { invites } from '~/server/db/schema';
import { adminProcedure, createTRPCRouter, publicProcedure } from '../trpc'; import { adminProcedure, createTRPCRouter, publicProcedure } from '../trpc';
@@ -14,22 +17,25 @@ export const inviteRouter = createTRPCRouter({
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const limit = input.limit ?? 50; const limit = input.limit ?? 50;
const invites = await ctx.prisma.invite.findMany({ const dbInvites = await db.query.invites.findMany({
take: limit, limit: limit,
skip: limit * input.page, offset: limit * input.page,
include: { with: {
createdBy: { createdBy: {
select: { columns: {
name: true, name: true,
}, },
}, },
}, },
}); });
const inviteCount = await ctx.prisma.invite.count(); const inviteCount = await db
.select({ count: sql<number>`count(*)` })
.from(invites)
.then((rows) => rows[0].count);
return { return {
invites: invites.map((token) => ({ invites: dbInvites.map((token) => ({
id: token.id, id: token.id,
expires: token.expires, expires: token.expires,
creator: token.createdBy.name, creator: token.createdBy.name,
@@ -47,27 +53,21 @@ export const inviteRouter = createTRPCRouter({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const token = await ctx.prisma.invite.create({ const inviteToInsert = {
data: { id: randomUUID(),
expires: input.expiration, expires: input.expiration,
createdById: ctx.session.user.id, createdById: ctx.session.user.id,
token: randomBytes(20).toString('hex'), token: randomBytes(20).toString('hex'),
}, };
}); await db.insert(invites).values(inviteToInsert);
return { return {
id: token.id, id: inviteToInsert.id,
token: token.token, token: inviteToInsert.token,
expires: token.expires, expires: inviteToInsert.expires,
}; };
}), }),
delete: adminProcedure delete: adminProcedure.input(z.object({ tokenId: z.string() })).mutation(async ({ input }) => {
.input(z.object({ tokenId: z.string() })) await db.delete(invites).where(eq(invites.id, input.tokenId));
.mutation(async ({ ctx, input }) => { }),
await ctx.prisma.invite.delete({
where: {
id: input.tokenId,
},
});
}),
}); });

View File

@@ -1,7 +1,11 @@
import { UserSettings } from '@prisma/client';
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { randomUUID } from 'crypto';
import { eq, like, sql } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { db } from '~/server/db';
import { getTotalUserCountAsync } from '~/server/db/queries/user';
import { UserSettings, invites, userSettings, users } from '~/server/db/schema';
import { hashPassword } from '~/utils/security'; import { hashPassword } from '~/utils/security';
import { import {
colorSchemeParser, colorSchemeParser,
@@ -11,24 +15,18 @@ import {
} from '~/validations/user'; } from '~/validations/user';
import { COOKIE_COLOR_SCHEME_KEY, COOKIE_LOCALE_KEY } from '../../../../data/constants'; import { COOKIE_COLOR_SCHEME_KEY, COOKIE_LOCALE_KEY } from '../../../../data/constants';
import { import { adminProcedure, createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
TRPCContext,
adminProcedure,
createTRPCRouter,
protectedProcedure,
publicProcedure,
} from '../trpc';
export const userRouter = createTRPCRouter({ export const userRouter = createTRPCRouter({
createOwnerAccount: publicProcedure.input(signUpFormSchema).mutation(async ({ ctx, input }) => { createOwnerAccount: publicProcedure.input(signUpFormSchema).mutation(async ({ ctx, input }) => {
const userCount = await ctx.prisma.user.count(); const userCount = await getTotalUserCountAsync();
if (userCount > 0) { if (userCount > 0) {
throw new TRPCError({ throw new TRPCError({
code: 'FORBIDDEN', code: 'FORBIDDEN',
}); });
} }
await createUserIfNotPresent(ctx, input, { await createUserIfNotPresent(input, {
defaultSettings: { defaultSettings: {
colorScheme: colorSchemeParser.parse(ctx.cookies[COOKIE_COLOR_SCHEME_KEY]), colorScheme: colorSchemeParser.parse(ctx.cookies[COOKIE_COLOR_SCHEME_KEY]),
language: ctx.cookies[COOKIE_LOCALE_KEY] ?? 'en', language: ctx.cookies[COOKIE_LOCALE_KEY] ?? 'en',
@@ -36,9 +34,8 @@ export const userRouter = createTRPCRouter({
isOwner: true, isOwner: true,
}); });
}), }),
count: publicProcedure.query(async ({ ctx }) => { count: publicProcedure.query(async () => {
const count = await ctx.prisma.user.count(); return await getTotalUserCountAsync();
return count;
}), }),
createFromInvite: publicProcedure createFromInvite: publicProcedure
.input( .input(
@@ -49,51 +46,29 @@ export const userRouter = createTRPCRouter({
) )
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const token = await ctx.prisma.invite.findUnique({ const invite = await db.query.invites.findFirst({
where: { where: eq(invites.token, input.inviteToken),
token: input.inviteToken,
},
}); });
if (!token || token.expires < new Date()) { if (!invite || invite.expires < new Date()) {
throw new TRPCError({ throw new TRPCError({
code: 'FORBIDDEN', code: 'FORBIDDEN',
message: 'Invalid invite token', message: 'Invalid invite token',
}); });
} }
await createUserIfNotPresent(ctx, input, { const userId = await createUserIfNotPresent(input, {
defaultSettings: { defaultSettings: {
colorScheme: colorSchemeParser.parse(ctx.cookies[COOKIE_COLOR_SCHEME_KEY]), colorScheme: colorSchemeParser.parse(ctx.cookies[COOKIE_COLOR_SCHEME_KEY]),
language: ctx.cookies[COOKIE_LOCALE_KEY] ?? 'en', language: ctx.cookies[COOKIE_LOCALE_KEY] ?? 'en',
}, },
}); });
const salt = bcrypt.genSaltSync(10); await db.delete(invites).where(eq(invites.id, invite.id));
const hashedPassword = hashPassword(input.password, salt);
const user = await ctx.prisma.user.create({
data: {
name: input.username,
password: hashedPassword,
salt: salt,
settings: {
create: {
colorScheme: colorSchemeParser.parse(ctx.cookies[COOKIE_COLOR_SCHEME_KEY]),
language: ctx.cookies[COOKIE_LOCALE_KEY] ?? 'en',
},
},
},
});
await ctx.prisma.invite.delete({
where: {
id: token.id,
},
});
return { return {
id: user.id, id: userId,
name: user.name, name: input.username,
}; };
}), }),
changeColorScheme: protectedProcedure changeColorScheme: protectedProcedure
@@ -103,18 +78,12 @@ export const userRouter = createTRPCRouter({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
await ctx.prisma.user.update({ await db
where: { .update(userSettings)
id: ctx.session?.user?.id, .set({
}, colorScheme: input.colorScheme,
data: { })
settings: { .where(eq(userSettings.userId, ctx.session?.user?.id));
update: {
colorScheme: input.colorScheme,
},
},
},
});
}), }),
changeRole: adminProcedure changeRole: adminProcedure
.input(z.object({ id: z.string(), type: z.enum(['promote', 'demote']) })) .input(z.object({ id: z.string(), type: z.enum(['promote', 'demote']) }))
@@ -126,10 +95,8 @@ export const userRouter = createTRPCRouter({
}); });
} }
const user = await ctx.prisma.user.findUnique({ const user = await db.query.users.findFirst({
where: { where: eq(users.id, input.id),
id: input.id,
},
}); });
if (!user) { if (!user) {
@@ -146,14 +113,10 @@ export const userRouter = createTRPCRouter({
}); });
} }
await ctx.prisma.user.update({ await db
where: { .update(users)
id: input.id, .set({ isAdmin: input.type === 'promote' })
}, .where(eq(users.id, input.id));
data: {
isAdmin: input.type === 'promote',
},
});
}), }),
changeLanguage: protectedProcedure changeLanguage: protectedProcedure
.input( .input(
@@ -162,25 +125,15 @@ export const userRouter = createTRPCRouter({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
await ctx.prisma.user.update({ await db
where: { .update(userSettings)
id: ctx.session?.user?.id, .set({ language: input.language })
}, .where(eq(userSettings.userId, ctx.session?.user?.id));
data: {
settings: {
update: {
language: input.language,
},
},
},
});
}), }),
withSettings: protectedProcedure.query(async ({ ctx, input }) => { withSettings: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.prisma.user.findUnique({ const user = await db.query.users.findFirst({
where: { where: eq(users.id, ctx.session?.user?.id),
id: ctx.session?.user?.id, with: {
},
include: {
settings: true, settings: true,
}, },
}); });
@@ -195,50 +148,26 @@ export const userRouter = createTRPCRouter({
return { return {
id: user.id, id: user.id,
name: user.name, name: user.name,
settings: { settings: user.settings,
...user.settings,
firstDayOfWeek: z
.enum(['monday', 'saturday', 'sunday'])
.parse(user.settings.firstDayOfWeek),
},
}; };
}), }),
updateSettings: protectedProcedure updateSettings: protectedProcedure
.input(updateSettingsValidationSchema) .input(updateSettingsValidationSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
await ctx.prisma.user.update({ await db
where: { .update(userSettings)
id: ctx.session.user.id, .set(input)
}, .where(eq(userSettings.userId, ctx.session?.user?.id));
data: {
settings: {
update: {
disablePingPulse: input.disablePingPulse,
replacePingWithIcons: input.replaceDotsWithIcons,
defaultBoard: input.defaultBoard,
language: input.language,
firstDayOfWeek: input.firstDayOfWeek,
searchTemplate: input.searchTemplate,
openSearchInNewTab: input.openSearchInNewTab,
autoFocusSearch: input.autoFocusSearch,
},
},
},
});
}), }),
makeDefaultDashboard: protectedProcedure makeDefaultDashboard: protectedProcedure
.input(z.object({ board: z.string() })) .input(z.object({ board: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
await ctx.prisma.userSettings.update({ await db
where: { .update(userSettings)
userId: ctx.session?.user.id, .set({ defaultBoard: input.board })
}, .where(eq(userSettings.userId, ctx.session?.user?.id));
data: {
defaultBoard: input.board,
},
});
}), }),
all: adminProcedure all: adminProcedure
@@ -254,26 +183,20 @@ export const userRouter = createTRPCRouter({
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const limit = input.limit; const limit = input.limit;
const users = await ctx.prisma.user.findMany({ const dbUsers = await db.query.users.findMany({
take: limit + 1, limit: limit + 1,
skip: limit * input.page, offset: limit * input.page,
where: { where: input.search ? like(users.name, `%${input.search}%`) : undefined,
name: {
contains: input.search,
},
},
}); });
const countUsers = await ctx.prisma.user.count({ const countUsers = await db
where: { .select({ count: sql<number>`count(*)` })
name: { .from(users)
contains: input.search, .where(input.search ? like(users.name, `%${input.search}%`) : undefined)
}, .then((rows) => rows[0].count);
},
});
return { return {
users: users.map((user) => ({ users: dbUsers.map((user) => ({
id: user.id, id: user.id,
name: user.name!, name: user.name!,
email: user.email, email: user.email,
@@ -284,7 +207,7 @@ export const userRouter = createTRPCRouter({
}; };
}), }),
create: adminProcedure.input(createNewUserSchema).mutation(async ({ ctx, input }) => { create: adminProcedure.input(createNewUserSchema).mutation(async ({ ctx, input }) => {
await createUserIfNotPresent(ctx, input); await createUserIfNotPresent(input);
}), }),
deleteUser: adminProcedure deleteUser: adminProcedure
@@ -294,10 +217,8 @@ export const userRouter = createTRPCRouter({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const user = await ctx.prisma.user.findUnique({ const user = await db.query.users.findFirst({
where: { where: eq(users.id, input.id),
id: input.id,
},
}); });
if (!user) { if (!user) {
@@ -320,26 +241,19 @@ export const userRouter = createTRPCRouter({
}); });
} }
await ctx.prisma.user.delete({ await db.delete(users).where(eq(users.id, input.id));
where: {
id: input.id,
},
});
}), }),
}); });
const createUserIfNotPresent = async ( const createUserIfNotPresent = async (
ctx: TRPCContext,
input: z.infer<typeof createNewUserSchema>, input: z.infer<typeof createNewUserSchema>,
options: { options: {
defaultSettings?: Partial<UserSettings>; defaultSettings?: Partial<UserSettings>;
isOwner?: boolean; isOwner?: boolean;
} | void } | void
) => { ) => {
const existingUser = await ctx.prisma.user.findFirst({ const existingUser = await db.query.users.findFirst({
where: { where: eq(users.name, input.username),
name: input.username,
},
}); });
if (existingUser) { if (existingUser) {
@@ -351,17 +265,22 @@ const createUserIfNotPresent = async (
const salt = bcrypt.genSaltSync(10); const salt = bcrypt.genSaltSync(10);
const hashedPassword = hashPassword(input.password, salt); const hashedPassword = hashPassword(input.password, salt);
await ctx.prisma.user.create({ const userId = randomUUID();
data: { await db.insert(users).values({
name: input.username, id: userId,
email: input.email, name: input.username,
password: hashedPassword, email: input.email,
salt: salt, password: hashedPassword,
isAdmin: options?.isOwner ?? false, salt: salt,
isOwner: options?.isOwner ?? false, isAdmin: options?.isOwner ?? false,
settings: { isOwner: options?.isOwner ?? false,
create: options?.defaultSettings ?? {},
},
},
}); });
await db.insert(userSettings).values({
id: randomUUID(),
userId,
...(options?.defaultSettings ?? {}),
});
return userId;
}; };

View File

@@ -13,7 +13,6 @@ import superjson from 'superjson';
import { ZodError } from 'zod'; import { ZodError } from 'zod';
import { getServerAuthSession } from '../auth'; import { getServerAuthSession } from '../auth';
import { prisma } from '../db';
/** /**
* 1. CONTEXT * 1. CONTEXT
@@ -41,7 +40,6 @@ interface CreateContextOptions {
const createInnerTRPCContext = (opts: CreateContextOptions) => ({ const createInnerTRPCContext = (opts: CreateContextOptions) => ({
session: opts.session, session: opts.session,
cookies: opts.cookies, cookies: opts.cookies,
prisma,
}); });
export type TRPCContext = ReturnType<typeof createInnerTRPCContext>; export type TRPCContext = ReturnType<typeof createInnerTRPCContext>;

View File

@@ -1,17 +1,20 @@
import { PrismaAdapter } from '@next-auth/prisma-adapter'; import { DrizzleAdapter } from '@auth/drizzle-adapter';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import Consola from 'consola'; import Consola from 'consola';
import Cookies from 'cookies'; import Cookies from 'cookies';
import { eq } from 'drizzle-orm';
import { type GetServerSidePropsContext, type NextApiRequest, type NextApiResponse } from 'next'; import { type GetServerSidePropsContext, type NextApiRequest, type NextApiResponse } from 'next';
import { type DefaultSession, type NextAuthOptions, getServerSession } from 'next-auth'; import { type DefaultSession, type NextAuthOptions, getServerSession } from 'next-auth';
import { Adapter } from 'next-auth/adapters'; import { Adapter } from 'next-auth/adapters';
import { decode, encode } from 'next-auth/jwt'; import { decode, encode } from 'next-auth/jwt';
import Credentials from 'next-auth/providers/credentials'; import Credentials from 'next-auth/providers/credentials';
import { prisma } from '~/server/db';
import EmptyNextAuthProvider from '~/utils/empty-provider'; import EmptyNextAuthProvider from '~/utils/empty-provider';
import { fromDate, generateSessionToken } from '~/utils/session'; import { fromDate, generateSessionToken } from '~/utils/session';
import { colorSchemeParser, signInSchema } from '~/validations/user'; import { colorSchemeParser, signInSchema } from '~/validations/user';
import { db } from './db';
import { users } from './db/schema';
/** /**
* Module augmentation for `next-auth` types. Allows us to add custom properties to the `session` * Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
* object and keep type safety. * object and keep type safety.
@@ -48,7 +51,7 @@ declare module 'next-auth/jwt' {
} }
} }
const adapter = PrismaAdapter(prisma); const adapter = DrizzleAdapter(db);
const sessionMaxAgeInSeconds = 30 * 24 * 60 * 60; // 30 days const sessionMaxAgeInSeconds = 30 * 24 * 60 * 60; // 30 days
/** /**
@@ -68,25 +71,25 @@ export const constructAuthOptions = (
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
session.user.name = user.name as string; session.user.name = user.name as string;
const userFromDatabase = await prisma.user.findUniqueOrThrow({ const userFromDatabase = await db.query.users.findFirst({
where: { with: {
id: user.id,
},
include: {
settings: { settings: {
select: { columns: {
colorScheme: true, colorScheme: true,
language: true, language: true,
autoFocusSearch: true, autoFocusSearch: true,
}, },
}, },
}, },
where: eq(users.id, user.id),
}); });
session.user.isAdmin = userFromDatabase.isAdmin; session.user.isAdmin = userFromDatabase?.isAdmin ?? false;
session.user.colorScheme = colorSchemeParser.parse(userFromDatabase.settings?.colorScheme); session.user.colorScheme = userFromDatabase
session.user.language = userFromDatabase.settings?.language ?? 'en'; ? colorSchemeParser.parse(userFromDatabase.settings?.colorScheme)
session.user.autoFocusSearch = userFromDatabase.settings?.autoFocusSearch ?? false; : 'environment';
session.user.language = userFromDatabase?.settings?.language ?? 'en';
session.user.autoFocusSearch = userFromDatabase?.settings?.autoFocusSearch ?? false;
} }
return session; return session;
@@ -129,7 +132,7 @@ export const constructAuthOptions = (
signIn: '/auth/login', signIn: '/auth/login',
error: '/auth/login', error: '/auth/login',
}, },
adapter: PrismaAdapter(prisma), adapter: adapter as Adapter,
providers: [ providers: [
Credentials({ Credentials({
name: 'credentials', name: 'credentials',
@@ -143,19 +146,17 @@ export const constructAuthOptions = (
async authorize(credentials) { async authorize(credentials) {
const data = await signInSchema.parseAsync(credentials); const data = await signInSchema.parseAsync(credentials);
const user = await prisma.user.findFirst({ const user = await db.query.users.findFirst({
where: { with: {
name: data.name,
},
include: {
settings: { settings: {
select: { columns: {
colorScheme: true, colorScheme: true,
language: true, language: true,
autoFocusSearch: true, autoFocusSearch: true,
}, },
}, },
}, },
where: eq(users.name, data.name),
}); });
if (!user || !user.password) { if (!user || !user.password) {

View File

@@ -1,14 +0,0 @@
import { PrismaClient } from '@prisma/client';
import { env } from '~/env';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: env.NEXT_PUBLIC_NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
if (env.NEXT_PUBLIC_NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

9
src/server/db/index.ts Normal file
View File

@@ -0,0 +1,9 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { env } from '~/env';
import * as schema from './schema';
const sqlite = new Database(env.DATABASE_URL?.replace('file:', ''));
export const db = drizzle(sqlite, { schema });

View File

@@ -0,0 +1,11 @@
import { sql } from 'drizzle-orm';
import { db } from '..';
import { users } from '../schema';
export const getTotalUserCountAsync = async () => {
return await db
.select({ count: sql<number>`count(*)` })
.from(users)
.then((rows) => rows[0].count);
};

View File

@@ -0,0 +1,18 @@
import { eq } from 'drizzle-orm';
import { db } from '..';
import { userSettings } from '../schema';
export const getDefaultBoardAsync = async (
userId: string | undefined,
fallback: string = 'default'
) => {
if (!userId) {
return fallback;
}
return await db.query.userSettings
.findFirst({
where: eq(userSettings.userId, userId),
})
.then((settings) => settings?.defaultBoard ?? fallback);
};

133
src/server/db/schema.ts Normal file
View File

@@ -0,0 +1,133 @@
import { InferSelectModel, relations } from 'drizzle-orm';
import { index, int, integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { type AdapterAccount } from 'next-auth/adapters';
export const users = sqliteTable('user', {
id: text('id').notNull().primaryKey(),
name: text('name'),
email: text('email'),
emailVerified: integer('emailVerified', { mode: 'timestamp_ms' }),
image: text('image'),
password: text('password'),
salt: text('salt'),
isAdmin: int('is_admin', { mode: 'boolean' }).notNull().default(false),
isOwner: int('is_owner', { mode: 'boolean' }).notNull().default(false),
});
export const accounts = sqliteTable(
'account',
{
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
type: text('type').$type<AdapterAccount['type']>().notNull(),
provider: text('provider').notNull(),
providerAccountId: text('providerAccountId').notNull(),
refresh_token: text('refresh_token'),
access_token: text('access_token'),
expires_at: integer('expires_at'),
token_type: text('token_type'),
scope: text('scope'),
id_token: text('id_token'),
session_state: text('session_state'),
},
(account) => ({
compoundKey: primaryKey(account.provider, account.providerAccountId),
userIdIdx: index('userId_idx').on(account.userId),
})
);
export const sessions = sqliteTable(
'session',
{
sessionToken: text('sessionToken').notNull().primaryKey(),
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expires: integer('expires', { mode: 'timestamp_ms' }).notNull(),
},
(session) => ({
userIdIdx: index('user_id_idx').on(session.userId),
})
);
export const verificationTokens = sqliteTable(
'verificationToken',
{
identifier: text('identifier').notNull(),
token: text('token').notNull(),
expires: integer('expires', { mode: 'timestamp_ms' }).notNull(),
},
(vt) => ({
compoundKey: primaryKey(vt.identifier, vt.token),
})
);
const validColorScheme = ['environment', 'light', 'dark'] as const;
type ValidColorScheme = (typeof validColorScheme)[number];
const firstDaysOfWeek = ['monday', 'saturday', 'sunday'] as const;
type ValidFirstDayOfWeek = (typeof firstDaysOfWeek)[number];
export const userSettings = sqliteTable('user_setting', {
id: text('id').notNull().primaryKey(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
colorScheme: text('color_scheme').$type<ValidColorScheme>().notNull().default('environment'),
language: text('language').notNull().default('en'),
defaultBoard: text('default_board').notNull().default('default'),
firstDayOfWeek: text('first_day_of_week')
.$type<ValidFirstDayOfWeek>()
.notNull()
.default('monday'),
searchTemplate: text('search_template').notNull().default('https://google.com/search?q=%s'),
openSearchInNewTab: int('open_search_in_new_tab', { mode: 'boolean' }).notNull().default(true),
disablePingPulse: int('disable_ping_pulse', { mode: 'boolean' }).notNull().default(false),
replacePingWithIcons: int('replace_ping_with_icons', { mode: 'boolean' })
.notNull()
.default(false),
useDebugLanguage: int('use_debug_language', { mode: 'boolean' }).notNull().default(false),
autoFocusSearch: int('auto_focus_search', { mode: 'boolean' }).notNull().default(false),
});
export type UserSettings = InferSelectModel<typeof userSettings>;
export const invites = sqliteTable('invite', {
id: text('id').notNull().primaryKey(),
token: text('token').notNull().unique(),
expires: int('expires', {
mode: 'timestamp',
}).notNull(),
createdById: text('created_by_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
});
export type Invite = InferSelectModel<typeof invites>;
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],
references: [users.id],
}),
}));
export const userRelations = relations(users, ({ many, one }) => ({
accounts: many(accounts),
settings: one(userSettings),
invites: many(invites),
}));
export const userSettingRelations = relations(userSettings, ({ one }) => ({
user: one(users, {
fields: [userSettings.userId],
references: [users.id],
}),
}));
export const inviteRelations = relations(invites, ({ one }) => ({
createdBy: one(users, {
fields: [invites.createdById],
references: [users.id],
}),
}));

View File

@@ -3,11 +3,11 @@ import { Calendar } from '@mantine/dates';
import { IconCalendarTime } from '@tabler/icons-react'; import { IconCalendarTime } from '@tabler/icons-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { useState } from 'react'; import { useState } from 'react';
import { useEditModeStore } from '~/components/Dashboard/Views/useEditModeStore';
import { useConfigContext } from '~/config/provider';
import { getLanguageByCode } from '~/tools/language'; import { getLanguageByCode } from '~/tools/language';
import { RouterOutputs, api } from '~/utils/api'; import { RouterOutputs, api } from '~/utils/api';
import { useEditModeStore } from '~/components/Dashboard/Views/useEditModeStore';
import { useConfigContext } from '~/config/provider';
import { defineWidget } from '../helper'; import { defineWidget } from '../helper';
import { IWidget } from '../widgets'; import { IWidget } from '../widgets';
import { CalendarDay } from './CalendarDay'; import { CalendarDay } from './CalendarDay';
@@ -33,22 +33,12 @@ const definition = defineWidget({
radarrReleaseType: { radarrReleaseType: {
type: 'select', type: 'select',
defaultValue: 'inCinemas', defaultValue: 'inCinemas',
data: [ data: [{ value: 'inCinemas' }, { value: 'physicalRelease' }, { value: 'digitalRelease' }],
{ value: 'inCinemas' },
{ value: 'physicalRelease' },
{ value: 'digitalRelease' },
],
}, },
fontSize: { fontSize: {
type: 'select', type: 'select',
defaultValue: 'xs', defaultValue: 'xs',
data: [ data: [{ value: 'xs' }, { value: 'sm' }, { value: 'md' }, { value: 'lg' }, { value: 'xl' }],
{ value: 'xs' },
{ value: 'sm' },
{ value: 'md' },
{ value: 'lg' },
{ value: 'xl' },
],
}, },
}, },
gridstack: { gridstack: {
@@ -83,7 +73,10 @@ function CalendarTile({ widget }: CalendarTileProps) {
configName: configName!, configName: configName!,
month: month.getMonth() + 1, month: month.getMonth() + 1,
year: month.getFullYear(), year: month.getFullYear(),
options: { useSonarrv4: widget.properties.useSonarrv4, showUnmonitored: widget.properties.showUnmonitored }, options: {
useSonarrv4: widget.properties.useSonarrv4,
showUnmonitored: widget.properties.showUnmonitored,
},
}, },
{ {
staleTime: 1000 * 60 * 60 * 5, staleTime: 1000 * 60 * 60 * 5,

View File

@@ -1,6 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5", "target": "es6",
"lib": [ "lib": [
"dom", "dom",
"dom.iterable", "dom.iterable",
@@ -36,5 +36,11 @@
], ],
"exclude": [ "exclude": [
"node_modules" "node_modules"
] ],
"ts-node": {
"esm": true,
"compilerOptions": {
"module": "nodenext",
},
},
} }

3363
yarn.lock

File diff suppressed because it is too large Load Diff