feat: initial drizzle setup

This commit is contained in:
diced
2025-09-04 22:05:21 -07:00
parent c15bf27b8a
commit 7b2af8b8c5
7 changed files with 3066 additions and 0 deletions

13
drizzle.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql',
out: './src/drizzle',
schema: './src/drizzle/schema.ts',
dbCredentials: {
url: process.env.DATABASE_URL as string,
},
verbose: true,
strict: true,
});

View File

@@ -0,0 +1,287 @@
CREATE TYPE "public"."IncompleteFileStatus" AS ENUM('PENDING', 'PROCESSING', 'COMPLETE', 'FAILED');--> statement-breakpoint
CREATE TYPE "public"."OAuthProviderType" AS ENUM('DISCORD', 'GOOGLE', 'GITHUB', 'OIDC');--> statement-breakpoint
CREATE TYPE "public"."Role" AS ENUM('USER', 'ADMIN', 'SUPERADMIN');--> statement-breakpoint
CREATE TYPE "public"."UserFilesQuota" AS ENUM('BY_BYTES', 'BY_FILES');--> statement-breakpoint
--> statement-breakpoint
CREATE TABLE "Zipline" (
"id" text PRIMARY KEY NOT NULL,
"createdAt" timestamp(3) DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp(3) NOT NULL,
"firstSetup" boolean DEFAULT true NOT NULL,
"coreReturnHttpsUrls" boolean DEFAULT false NOT NULL,
"coreDefaultDomain" text,
"coreTempDirectory" text NOT NULL,
"chunksEnabled" boolean DEFAULT true NOT NULL,
"chunksMax" text DEFAULT '95mb' NOT NULL,
"chunksSize" text DEFAULT '25mb' NOT NULL,
"tasksDeleteInterval" text DEFAULT '30m' NOT NULL,
"tasksClearInvitesInterval" text DEFAULT '30m' NOT NULL,
"tasksMaxViewsInterval" text DEFAULT '30m' NOT NULL,
"tasksThumbnailsInterval" text DEFAULT '30m' NOT NULL,
"tasksMetricsInterval" text DEFAULT '30m' NOT NULL,
"filesRoute" text DEFAULT '/u' NOT NULL,
"filesLength" integer DEFAULT 6 NOT NULL,
"filesDefaultFormat" text DEFAULT 'random' NOT NULL,
"filesDisabledExtensions" text[],
"filesMaxFileSize" text DEFAULT '100mb' NOT NULL,
"filesDefaultExpiration" text,
"filesAssumeMimetypes" boolean DEFAULT false NOT NULL,
"filesDefaultDateFormat" text DEFAULT 'YYYY-MM-DD_HH:mm:ss' NOT NULL,
"filesRemoveGpsMetadata" boolean DEFAULT false NOT NULL,
"urlsRoute" text DEFAULT '/go' NOT NULL,
"urlsLength" integer DEFAULT 6 NOT NULL,
"featuresImageCompression" boolean DEFAULT true NOT NULL,
"featuresRobotsTxt" boolean DEFAULT true NOT NULL,
"featuresHealthcheck" boolean DEFAULT true NOT NULL,
"featuresUserRegistration" boolean DEFAULT false NOT NULL,
"featuresOauthRegistration" boolean DEFAULT false NOT NULL,
"featuresDeleteOnMaxViews" boolean DEFAULT true NOT NULL,
"featuresThumbnailsEnabled" boolean DEFAULT true NOT NULL,
"featuresThumbnailsNumberThreads" integer DEFAULT 4 NOT NULL,
"featuresMetricsEnabled" boolean DEFAULT true NOT NULL,
"featuresMetricsAdminOnly" boolean DEFAULT false NOT NULL,
"featuresMetricsShowUserSpecific" boolean DEFAULT true NOT NULL,
"invitesEnabled" boolean DEFAULT true NOT NULL,
"invitesLength" integer DEFAULT 6 NOT NULL,
"websiteTitle" text DEFAULT 'Zipline' NOT NULL,
"websiteTitleLogo" text,
"websiteExternalLinks" jsonb DEFAULT '[{"url":"https://github.com/diced/zipline","name":"GitHub"},{"url":"https://zipline.diced.sh/","name":"Documentation"}]'::jsonb NOT NULL,
"websiteLoginBackground" text,
"websiteDefaultAvatar" text,
"websiteTos" text,
"websiteThemeDefault" text DEFAULT 'system' NOT NULL,
"websiteThemeDark" text DEFAULT 'builtin:dark_gray' NOT NULL,
"websiteThemeLight" text DEFAULT 'builtin:light_gray' NOT NULL,
"oauthBypassLocalLogin" boolean DEFAULT false NOT NULL,
"oauthLoginOnly" boolean DEFAULT false NOT NULL,
"oauthDiscordClientId" text,
"oauthDiscordClientSecret" text,
"oauthDiscordRedirectUri" text,
"oauthGoogleClientId" text,
"oauthGoogleClientSecret" text,
"oauthGoogleRedirectUri" text,
"oauthGithubClientId" text,
"oauthGithubClientSecret" text,
"oauthGithubRedirectUri" text,
"oauthOidcClientId" text,
"oauthOidcClientSecret" text,
"oauthOidcAuthorizeUrl" text,
"oauthOidcTokenUrl" text,
"oauthOidcUserinfoUrl" text,
"oauthOidcRedirectUri" text,
"mfaTotpEnabled" boolean DEFAULT false NOT NULL,
"mfaTotpIssuer" text DEFAULT 'Zipline' NOT NULL,
"mfaPasskeys" boolean DEFAULT false NOT NULL,
"ratelimitEnabled" boolean DEFAULT true NOT NULL,
"ratelimitMax" integer DEFAULT 10 NOT NULL,
"ratelimitWindow" integer,
"ratelimitAdminBypass" boolean DEFAULT true NOT NULL,
"ratelimitAllowList" text[],
"httpWebhookOnUpload" text,
"httpWebhookOnShorten" text,
"discordWebhookUrl" text,
"discordUsername" text,
"discordAvatarUrl" text,
"discordOnUploadWebhookUrl" text,
"discordOnUploadUsername" text,
"discordOnUploadAvatarUrl" text,
"discordOnUploadContent" text,
"discordOnUploadEmbed" jsonb,
"discordOnShortenWebhookUrl" text,
"discordOnShortenUsername" text,
"discordOnShortenAvatarUrl" text,
"discordOnShortenContent" text,
"discordOnShortenEmbed" jsonb,
"pwaEnabled" boolean DEFAULT false NOT NULL,
"pwaTitle" text DEFAULT 'Zipline' NOT NULL,
"pwaShortName" text DEFAULT 'Zipline' NOT NULL,
"pwaDescription" text DEFAULT 'Zipline' NOT NULL,
"pwaThemeColor" text DEFAULT '#000000' NOT NULL,
"pwaBackgroundColor" text DEFAULT '#000000' NOT NULL,
"websiteLoginBackgroundBlur" boolean DEFAULT true NOT NULL,
"filesRandomWordsNumAdjectives" integer DEFAULT 2 NOT NULL,
"filesRandomWordsSeparator" text DEFAULT '-' NOT NULL,
"featuresVersionAPI" text DEFAULT 'https://zipline-version.diced.sh' NOT NULL,
"featuresVersionChecking" boolean DEFAULT true NOT NULL,
"oauthDiscordAllowedIds" text[] DEFAULT '{"RAY"}',
"oauthDiscordDeniedIds" text[] DEFAULT '{"RAY"}',
"domains" text[] DEFAULT '{"RAY"}',
"filesDefaultCompressionFormat" text DEFAULT 'jpg',
"featuresThumbnailsFormat" text DEFAULT 'jpg' NOT NULL
);
--> statement-breakpoint
CREATE TABLE "Metric" (
"id" text PRIMARY KEY NOT NULL,
"createdAt" timestamp(3) DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp(3) NOT NULL,
"data" jsonb NOT NULL
);
--> statement-breakpoint
CREATE TABLE "Url" (
"id" text PRIMARY KEY NOT NULL,
"createdAt" timestamp(3) DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp(3) NOT NULL,
"code" text NOT NULL,
"vanity" text,
"destination" text NOT NULL,
"views" integer DEFAULT 0 NOT NULL,
"maxViews" integer,
"password" text,
"userId" text,
"enabled" boolean DEFAULT true NOT NULL
);
--> statement-breakpoint
CREATE TABLE "Folder" (
"id" text PRIMARY KEY NOT NULL,
"createdAt" timestamp(3) DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp(3) NOT NULL,
"name" text NOT NULL,
"public" boolean DEFAULT false NOT NULL,
"userId" text NOT NULL,
"allowUploads" boolean DEFAULT false NOT NULL
);
--> statement-breakpoint
CREATE TABLE "User" (
"id" text PRIMARY KEY NOT NULL,
"createdAt" timestamp(3) DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp(3) NOT NULL,
"username" text NOT NULL,
"password" text,
"avatar" text,
"token" text NOT NULL,
"role" "Role" DEFAULT 'USER' NOT NULL,
"view" jsonb DEFAULT '{}'::jsonb NOT NULL,
"totpSecret" text,
"sessions" text[]
);
--> statement-breakpoint
CREATE TABLE "Export" (
"id" text PRIMARY KEY NOT NULL,
"createdAt" timestamp(3) DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp(3) NOT NULL,
"completed" boolean DEFAULT false NOT NULL,
"path" text NOT NULL,
"files" integer NOT NULL,
"size" text NOT NULL,
"userId" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "UserQuota" (
"id" text PRIMARY KEY NOT NULL,
"createdAt" timestamp(3) DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp(3) NOT NULL,
"filesQuota" "UserFilesQuota" NOT NULL,
"maxBytes" text,
"maxFiles" integer,
"maxUrls" integer,
"userId" text
);
--> statement-breakpoint
CREATE TABLE "UserPasskey" (
"id" text PRIMARY KEY NOT NULL,
"createdAt" timestamp(3) DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp(3) NOT NULL,
"lastUsed" timestamp(3),
"name" text NOT NULL,
"reg" jsonb NOT NULL,
"userId" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "OAuthProvider" (
"id" text PRIMARY KEY NOT NULL,
"createdAt" timestamp(3) DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp(3) NOT NULL,
"userId" text NOT NULL,
"provider" "OAuthProviderType" NOT NULL,
"username" text NOT NULL,
"accessToken" text NOT NULL,
"refreshToken" text,
"oauthId" text
);
--> statement-breakpoint
CREATE TABLE "File" (
"id" text PRIMARY KEY NOT NULL,
"createdAt" timestamp(3) DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp(3) NOT NULL,
"deletesAt" timestamp(3),
"name" text NOT NULL,
"originalName" text,
"size" bigint NOT NULL,
"type" text NOT NULL,
"views" integer DEFAULT 0 NOT NULL,
"maxViews" integer,
"favorite" boolean DEFAULT false NOT NULL,
"password" text,
"userId" text,
"folderId" text
);
--> statement-breakpoint
CREATE TABLE "Thumbnail" (
"id" text PRIMARY KEY NOT NULL,
"createdAt" timestamp(3) DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp(3) NOT NULL,
"path" text NOT NULL,
"fileId" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "IncompleteFile" (
"id" text PRIMARY KEY NOT NULL,
"createdAt" timestamp(3) DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp(3) NOT NULL,
"status" "IncompleteFileStatus" NOT NULL,
"chunksTotal" integer NOT NULL,
"chunksComplete" integer NOT NULL,
"metadata" jsonb NOT NULL,
"userId" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "Tag" (
"id" text PRIMARY KEY NOT NULL,
"createdAt" timestamp(3) DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp(3) NOT NULL,
"name" text NOT NULL,
"color" text NOT NULL,
"userId" text
);
--> statement-breakpoint
CREATE TABLE "Invite" (
"id" text PRIMARY KEY NOT NULL,
"createdAt" timestamp(3) DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp(3) NOT NULL,
"expiresAt" timestamp(3),
"code" text NOT NULL,
"uses" integer DEFAULT 0 NOT NULL,
"maxUses" integer,
"inviterId" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "_FileToTag" (
"A" text NOT NULL,
"B" text NOT NULL,
CONSTRAINT "_FileToTag_AB_pkey" PRIMARY KEY("A","B")
);
--> statement-breakpoint
ALTER TABLE "Url" ADD CONSTRAINT "Url_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "Folder" ADD CONSTRAINT "Folder_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "Export" ADD CONSTRAINT "Export_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "UserQuota" ADD CONSTRAINT "UserQuota_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "UserPasskey" ADD CONSTRAINT "UserPasskey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "OAuthProvider" ADD CONSTRAINT "OAuthProvider_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE restrict ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "File" ADD CONSTRAINT "File_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "File" ADD CONSTRAINT "File_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "public"."Folder"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "Thumbnail" ADD CONSTRAINT "Thumbnail_fileId_fkey" FOREIGN KEY ("fileId") REFERENCES "public"."File"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "IncompleteFile" ADD CONSTRAINT "IncompleteFile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "Tag" ADD CONSTRAINT "Tag_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "Invite" ADD CONSTRAINT "Invite_inviterId_fkey" FOREIGN KEY ("inviterId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "_FileToTag" ADD CONSTRAINT "_FileToTag_A_fkey" FOREIGN KEY ("A") REFERENCES "public"."File"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "_FileToTag" ADD CONSTRAINT "_FileToTag_B_fkey" FOREIGN KEY ("B") REFERENCES "public"."Tag"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
CREATE UNIQUE INDEX "Url_code_vanity_key" ON "Url" USING btree ("code" text_ops,"vanity" text_ops);--> statement-breakpoint
CREATE UNIQUE INDEX "User_token_key" ON "User" USING btree ("token" text_ops);--> statement-breakpoint
CREATE UNIQUE INDEX "User_username_key" ON "User" USING btree ("username" text_ops);--> statement-breakpoint
CREATE UNIQUE INDEX "UserQuota_userId_key" ON "UserQuota" USING btree ("userId" text_ops);--> statement-breakpoint
CREATE UNIQUE INDEX "OAuthProvider_provider_oauthId_key" ON "OAuthProvider" USING btree ("provider" text_ops,"oauthId" text_ops);--> statement-breakpoint
CREATE UNIQUE INDEX "Thumbnail_fileId_key" ON "Thumbnail" USING btree ("fileId" text_ops);--> statement-breakpoint
CREATE UNIQUE INDEX "Tag_name_key" ON "Tag" USING btree ("name" text_ops);--> statement-breakpoint
CREATE UNIQUE INDEX "Invite_code_key" ON "Invite" USING btree ("code" text_ops);--> statement-breakpoint
CREATE INDEX "_FileToTag_B_index" ON "_FileToTag" USING btree ("B" text_ops);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1756926875085,
"tag": "0000_bouncy_mantis",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1756931651183,
"tag": "0001_next_red_ghost",
"breakpoints": true
}
]
}

125
src/drizzle/relations.ts Normal file
View File

@@ -0,0 +1,125 @@
import { relations } from 'drizzle-orm/relations';
import {
user,
url,
folder,
exportTable,
userQuota,
userPasskey,
oauthProvider,
file,
thumbnail,
incompleteFile,
tag,
invite,
fileToTag,
} from './schema';
export const urlRelations = relations(url, ({ one }) => ({
user: one(user, {
fields: [url.userId],
references: [user.id],
}),
}));
export const userRelations = relations(user, ({ many }) => ({
urls: many(url),
folders: many(folder),
exports: many(exportTable),
userQuotas: many(userQuota),
userPasskeys: many(userPasskey),
oauthProviders: many(oauthProvider),
files: many(file),
incompleteFiles: many(incompleteFile),
tags: many(tag),
invites: many(invite),
}));
export const folderRelations = relations(folder, ({ one, many }) => ({
user: one(user, {
fields: [folder.userId],
references: [user.id],
}),
files: many(file),
}));
export const exportRelations = relations(exportTable, ({ one }) => ({
user: one(user, {
fields: [exportTable.userId],
references: [user.id],
}),
}));
export const userQuotaRelations = relations(userQuota, ({ one }) => ({
user: one(user, {
fields: [userQuota.userId],
references: [user.id],
}),
}));
export const userPasskeyRelations = relations(userPasskey, ({ one }) => ({
user: one(user, {
fields: [userPasskey.userId],
references: [user.id],
}),
}));
export const oauthProviderRelations = relations(oauthProvider, ({ one }) => ({
user: one(user, {
fields: [oauthProvider.userId],
references: [user.id],
}),
}));
export const fileRelations = relations(file, ({ one, many }) => ({
user: one(user, {
fields: [file.userId],
references: [user.id],
}),
folder: one(folder, {
fields: [file.folderId],
references: [folder.id],
}),
thumbnails: many(thumbnail),
fileToTags: many(fileToTag),
}));
export const thumbnailRelations = relations(thumbnail, ({ one }) => ({
file: one(file, {
fields: [thumbnail.fileId],
references: [file.id],
}),
}));
export const incompleteFileRelations = relations(incompleteFile, ({ one }) => ({
user: one(user, {
fields: [incompleteFile.userId],
references: [user.id],
}),
}));
export const tagRelations = relations(tag, ({ one, many }) => ({
user: one(user, {
fields: [tag.userId],
references: [user.id],
}),
fileToTags: many(fileToTag),
}));
export const inviteRelations = relations(invite, ({ one }) => ({
user: one(user, {
fields: [invite.inviterId],
references: [user.id],
}),
}));
export const fileToTagRelations = relations(fileToTag, ({ one }) => ({
file: one(file, {
fields: [fileToTag.a],
references: [file.id],
}),
tag: one(tag, {
fields: [fileToTag.b],
references: [tag.id],
}),
}));

497
src/drizzle/schema.ts Normal file
View File

@@ -0,0 +1,497 @@
import {
pgTable,
timestamp,
text,
integer,
boolean,
jsonb,
uniqueIndex,
foreignKey,
bigint,
index,
primaryKey,
pgEnum,
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
export const incompleteFileStatus = pgEnum('IncompleteFileStatus', [
'PENDING',
'PROCESSING',
'COMPLETE',
'FAILED',
]);
export const oauthProviderType = pgEnum('OAuthProviderType', ['DISCORD', 'GOOGLE', 'GITHUB', 'OIDC']);
export const role = pgEnum('Role', ['USER', 'ADMIN', 'SUPERADMIN']);
export const userFilesQuota = pgEnum('UserFilesQuota', ['BY_BYTES', 'BY_FILES']);
export const zipline = pgTable('Zipline', {
id: text().primaryKey().notNull(),
createdAt: timestamp({ precision: 3, mode: 'string' })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: timestamp({ precision: 3, mode: 'string' }).notNull(),
firstSetup: boolean().default(true).notNull(),
coreReturnHttpsUrls: boolean().default(false).notNull(),
coreDefaultDomain: text(),
coreTempDirectory: text().notNull(),
chunksEnabled: boolean().default(true).notNull(),
chunksMax: text().default('95mb').notNull(),
chunksSize: text().default('25mb').notNull(),
tasksDeleteInterval: text().default('30m').notNull(),
tasksClearInvitesInterval: text().default('30m').notNull(),
tasksMaxViewsInterval: text().default('30m').notNull(),
tasksThumbnailsInterval: text().default('30m').notNull(),
tasksMetricsInterval: text().default('30m').notNull(),
filesRoute: text().default('/u').notNull(),
filesLength: integer().default(6).notNull(),
filesDefaultFormat: text().default('random').notNull(),
filesDisabledExtensions: text().array(),
filesMaxFileSize: text().default('100mb').notNull(),
filesDefaultExpiration: text(),
filesAssumeMimetypes: boolean().default(false).notNull(),
filesDefaultDateFormat: text().default('YYYY-MM-DD_HH:mm:ss').notNull(),
filesRemoveGpsMetadata: boolean().default(false).notNull(),
urlsRoute: text().default('/go').notNull(),
urlsLength: integer().default(6).notNull(),
featuresImageCompression: boolean().default(true).notNull(),
featuresRobotsTxt: boolean().default(true).notNull(),
featuresHealthcheck: boolean().default(true).notNull(),
featuresUserRegistration: boolean().default(false).notNull(),
featuresOauthRegistration: boolean().default(false).notNull(),
featuresDeleteOnMaxViews: boolean().default(true).notNull(),
featuresThumbnailsEnabled: boolean().default(true).notNull(),
featuresThumbnailsNumberThreads: integer().default(4).notNull(),
featuresMetricsEnabled: boolean().default(true).notNull(),
featuresMetricsAdminOnly: boolean().default(false).notNull(),
featuresMetricsShowUserSpecific: boolean().default(true).notNull(),
invitesEnabled: boolean().default(true).notNull(),
invitesLength: integer().default(6).notNull(),
websiteTitle: text().default('Zipline').notNull(),
websiteTitleLogo: text(),
websiteExternalLinks: jsonb()
.default([
{ url: 'https://github.com/diced/zipline', name: 'GitHub' },
{ url: 'https://zipline.diced.sh/', name: 'Documentation' },
])
.notNull(),
websiteLoginBackground: text(),
websiteDefaultAvatar: text(),
websiteTos: text(),
websiteThemeDefault: text().default('system').notNull(),
websiteThemeDark: text().default('builtin:dark_gray').notNull(),
websiteThemeLight: text().default('builtin:light_gray').notNull(),
oauthBypassLocalLogin: boolean().default(false).notNull(),
oauthLoginOnly: boolean().default(false).notNull(),
oauthDiscordClientId: text(),
oauthDiscordClientSecret: text(),
oauthDiscordRedirectUri: text(),
oauthGoogleClientId: text(),
oauthGoogleClientSecret: text(),
oauthGoogleRedirectUri: text(),
oauthGithubClientId: text(),
oauthGithubClientSecret: text(),
oauthGithubRedirectUri: text(),
oauthOidcClientId: text(),
oauthOidcClientSecret: text(),
oauthOidcAuthorizeUrl: text(),
oauthOidcTokenUrl: text(),
oauthOidcUserinfoUrl: text(),
oauthOidcRedirectUri: text(),
mfaTotpEnabled: boolean().default(false).notNull(),
mfaTotpIssuer: text().default('Zipline').notNull(),
mfaPasskeys: boolean().default(false).notNull(),
ratelimitEnabled: boolean().default(true).notNull(),
ratelimitMax: integer().default(10).notNull(),
ratelimitWindow: integer(),
ratelimitAdminBypass: boolean().default(true).notNull(),
ratelimitAllowList: text().array(),
httpWebhookOnUpload: text(),
httpWebhookOnShorten: text(),
discordWebhookUrl: text(),
discordUsername: text(),
discordAvatarUrl: text(),
discordOnUploadWebhookUrl: text(),
discordOnUploadUsername: text(),
discordOnUploadAvatarUrl: text(),
discordOnUploadContent: text(),
discordOnUploadEmbed: jsonb(),
discordOnShortenWebhookUrl: text(),
discordOnShortenUsername: text(),
discordOnShortenAvatarUrl: text(),
discordOnShortenContent: text(),
discordOnShortenEmbed: jsonb(),
pwaEnabled: boolean().default(false).notNull(),
pwaTitle: text().default('Zipline').notNull(),
pwaShortName: text().default('Zipline').notNull(),
pwaDescription: text().default('Zipline').notNull(),
pwaThemeColor: text().default('#000000').notNull(),
pwaBackgroundColor: text().default('#000000').notNull(),
websiteLoginBackgroundBlur: boolean().default(true).notNull(),
filesRandomWordsNumAdjectives: integer().default(2).notNull(),
filesRandomWordsSeparator: text().default('-').notNull(),
featuresVersionAPI: text().default('https://zipline-version.diced.sh').notNull(),
featuresVersionChecking: boolean().default(true).notNull(),
oauthDiscordAllowedIds: text().array().default(['RAY']),
oauthDiscordDeniedIds: text().array().default(['RAY']),
domains: text().array().default(['RAY']),
filesDefaultCompressionFormat: text().default('jpg'),
featuresThumbnailsFormat: text().default('jpg').notNull(),
});
export const metric = pgTable('Metric', {
id: text().primaryKey().notNull(),
createdAt: timestamp({ precision: 3, mode: 'string' })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: timestamp({ precision: 3, mode: 'string' }).notNull(),
data: jsonb().notNull(),
});
export const url = pgTable(
'Url',
{
id: text().primaryKey().notNull(),
createdAt: timestamp({ precision: 3, mode: 'string' })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: timestamp({ precision: 3, mode: 'string' }).notNull(),
code: text().notNull(),
vanity: text(),
destination: text().notNull(),
views: integer().default(0).notNull(),
maxViews: integer(),
password: text(),
userId: text(),
enabled: boolean().default(true).notNull(),
},
(table) => [
uniqueIndex('Url_code_vanity_key').using(
'btree',
table.code.asc().nullsLast().op('text_ops'),
table.vanity.asc().nullsLast().op('text_ops'),
),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: 'Url_userId_fkey',
})
.onUpdate('cascade')
.onDelete('set null'),
],
);
export const folder = pgTable(
'Folder',
{
id: text().primaryKey().notNull(),
createdAt: timestamp({ precision: 3, mode: 'string' })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: timestamp({ precision: 3, mode: 'string' }).notNull(),
name: text().notNull(),
public: boolean().default(false).notNull(),
userId: text().notNull(),
allowUploads: boolean().default(false).notNull(),
},
(table) => [
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: 'Folder_userId_fkey',
})
.onUpdate('cascade')
.onDelete('cascade'),
],
);
export const user = pgTable(
'User',
{
id: text().primaryKey().notNull(),
createdAt: timestamp({ precision: 3, mode: 'string' })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: timestamp({ precision: 3, mode: 'string' }).notNull(),
username: text().notNull(),
password: text(),
avatar: text(),
token: text().notNull(),
role: role().default('USER').notNull(),
view: jsonb().default({}).notNull(),
totpSecret: text(),
sessions: text().array(),
},
(table) => [
uniqueIndex('User_token_key').using('btree', table.token.asc().nullsLast().op('text_ops')),
uniqueIndex('User_username_key').using('btree', table.username.asc().nullsLast().op('text_ops')),
],
);
export const exportTable = pgTable(
'Export',
{
id: text().primaryKey().notNull(),
createdAt: timestamp({ precision: 3, mode: 'string' })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: timestamp({ precision: 3, mode: 'string' }).notNull(),
completed: boolean().default(false).notNull(),
path: text().notNull(),
files: integer().notNull(),
size: text().notNull(),
userId: text().notNull(),
},
(table) => [
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: 'Export_userId_fkey',
})
.onUpdate('cascade')
.onDelete('cascade'),
],
);
export const userQuota = pgTable(
'UserQuota',
{
id: text().primaryKey().notNull(),
createdAt: timestamp({ precision: 3, mode: 'string' })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: timestamp({ precision: 3, mode: 'string' }).notNull(),
filesQuota: userFilesQuota().notNull(),
maxBytes: text(),
maxFiles: integer(),
maxUrls: integer(),
userId: text(),
},
(table) => [
uniqueIndex('UserQuota_userId_key').using('btree', table.userId.asc().nullsLast().op('text_ops')),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: 'UserQuota_userId_fkey',
})
.onUpdate('cascade')
.onDelete('cascade'),
],
);
export const userPasskey = pgTable(
'UserPasskey',
{
id: text().primaryKey().notNull(),
createdAt: timestamp({ precision: 3, mode: 'string' })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: timestamp({ precision: 3, mode: 'string' }).notNull(),
lastUsed: timestamp({ precision: 3, mode: 'string' }),
name: text().notNull(),
reg: jsonb().notNull(),
userId: text().notNull(),
},
(table) => [
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: 'UserPasskey_userId_fkey',
})
.onUpdate('cascade')
.onDelete('cascade'),
],
);
export const oauthProvider = pgTable(
'OAuthProvider',
{
id: text().primaryKey().notNull(),
createdAt: timestamp({ precision: 3, mode: 'string' })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: timestamp({ precision: 3, mode: 'string' }).notNull(),
userId: text().notNull(),
provider: oauthProviderType().notNull(),
username: text().notNull(),
accessToken: text().notNull(),
refreshToken: text(),
oauthId: text(),
},
(table) => [
uniqueIndex('OAuthProvider_provider_oauthId_key').using(
'btree',
table.provider.asc().nullsLast().op('text_ops'),
table.oauthId.asc().nullsLast().op('text_ops'),
),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: 'OAuthProvider_userId_fkey',
})
.onUpdate('cascade')
.onDelete('restrict'),
],
);
export const file = pgTable(
'File',
{
id: text().primaryKey().notNull(),
createdAt: timestamp({ precision: 3, mode: 'string' })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: timestamp({ precision: 3, mode: 'string' }).notNull(),
deletesAt: timestamp({ precision: 3, mode: 'string' }),
name: text().notNull(),
originalName: text(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
size: bigint({ mode: 'number' }).notNull(),
type: text().notNull(),
views: integer().default(0).notNull(),
maxViews: integer(),
favorite: boolean().default(false).notNull(),
password: text(),
userId: text(),
folderId: text(),
},
(table) => [
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: 'File_userId_fkey',
})
.onUpdate('cascade')
.onDelete('set null'),
foreignKey({
columns: [table.folderId],
foreignColumns: [folder.id],
name: 'File_folderId_fkey',
})
.onUpdate('cascade')
.onDelete('set null'),
],
);
export const thumbnail = pgTable(
'Thumbnail',
{
id: text().primaryKey().notNull(),
createdAt: timestamp({ precision: 3, mode: 'string' })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: timestamp({ precision: 3, mode: 'string' }).notNull(),
path: text().notNull(),
fileId: text().notNull(),
},
(table) => [
uniqueIndex('Thumbnail_fileId_key').using('btree', table.fileId.asc().nullsLast().op('text_ops')),
foreignKey({
columns: [table.fileId],
foreignColumns: [file.id],
name: 'Thumbnail_fileId_fkey',
})
.onUpdate('cascade')
.onDelete('cascade'),
],
);
export const incompleteFile = pgTable(
'IncompleteFile',
{
id: text().primaryKey().notNull(),
createdAt: timestamp({ precision: 3, mode: 'string' })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: timestamp({ precision: 3, mode: 'string' }).notNull(),
status: incompleteFileStatus().notNull(),
chunksTotal: integer().notNull(),
chunksComplete: integer().notNull(),
metadata: jsonb().notNull(),
userId: text().notNull(),
},
(table) => [
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: 'IncompleteFile_userId_fkey',
})
.onUpdate('cascade')
.onDelete('cascade'),
],
);
export const tag = pgTable(
'Tag',
{
id: text().primaryKey().notNull(),
createdAt: timestamp({ precision: 3, mode: 'string' })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: timestamp({ precision: 3, mode: 'string' }).notNull(),
name: text().notNull(),
color: text().notNull(),
userId: text(),
},
(table) => [
uniqueIndex('Tag_name_key').using('btree', table.name.asc().nullsLast().op('text_ops')),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: 'Tag_userId_fkey',
})
.onUpdate('cascade')
.onDelete('set null'),
],
);
export const invite = pgTable(
'Invite',
{
id: text().primaryKey().notNull(),
createdAt: timestamp({ precision: 3, mode: 'string' })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: timestamp({ precision: 3, mode: 'string' }).notNull(),
expiresAt: timestamp({ precision: 3, mode: 'string' }),
code: text().notNull(),
uses: integer().default(0).notNull(),
maxUses: integer(),
inviterId: text().notNull(),
},
(table) => [
uniqueIndex('Invite_code_key').using('btree', table.code.asc().nullsLast().op('text_ops')),
foreignKey({
columns: [table.inviterId],
foreignColumns: [user.id],
name: 'Invite_inviterId_fkey',
})
.onUpdate('cascade')
.onDelete('cascade'),
],
);
export const fileToTag = pgTable(
'_FileToTag',
{
a: text('A').notNull(),
b: text('B').notNull(),
},
(table) => [
index().using('btree', table.b.asc().nullsLast().op('text_ops')),
foreignKey({
columns: [table.a],
foreignColumns: [file.id],
name: '_FileToTag_A_fkey',
})
.onUpdate('cascade')
.onDelete('cascade'),
foreignKey({
columns: [table.b],
foreignColumns: [tag.id],
name: '_FileToTag_B_fkey',
})
.onUpdate('cascade')
.onDelete('cascade'),
primaryKey({ columns: [table.a, table.b], name: '_FileToTag_AB_pkey' }),
],
);

View File

@@ -0,0 +1,87 @@
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { drizzle } from 'drizzle-orm/node-postgres';
import pg from 'pg';
import { join } from 'path';
import { log } from '@/lib/logger';
const logger = log('db').c('drizzle');
async function drizzleBootstrap(client: pg.Client) {
await client.query('CREATE SCHEMA IF NOT EXISTS "drizzle"');
await client.query(`
CREATE TABLE IF NOT EXISTS "drizzle"."__drizzle_migrations" (
id SERIAL PRIMARY KEY,
hash text NOT NULL,
created_at numeric
)
`);
}
async function migrateExistingPrisma(client: pg.Client) {
// check if there is a _prisma_migrations table
// if there is then we continue with prisma -> drizzle.
const resPrisma = await client.query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = '_prisma_migrations'
)
`);
const existsPrisma = resPrisma.rows[0]?.exists;
if (!existsPrisma) {
logger.debug('no existing prisma migrations found, skipping prisma -> drizzle migration step');
return;
}
logger.debug('existing prisma migrations found, migrating to drizzle');
// at this point, there should already be a __drizzle_migrations table
// now looking for the first migration so we can manually insert it if needed.
const firstMigration = 1756926875085;
const res = await client.query(
`
SELECT COUNT(*) FROM drizzle.__drizzle_migrations WHERE created_at = $1
`,
[firstMigration],
);
const count = parseInt(res.rows[0]?.count || '0', 10);
logger.debug('finding existing first migrations', { count });
if (count === 0) {
logger.debug('inserting first migration manually');
await client.query(
`
INSERT INTO drizzle.__drizzle_migrations (created_at, hash)
VALUES ($1, $2)
`,
[firstMigration, 'manual'],
);
}
}
export async function runDrizzleMigrations() {
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
await client.connect();
const db = drizzle(client);
// ensure drizzle migrations table exists
await drizzleBootstrap(client);
// migrate from prisma to drizzle
await migrateExistingPrisma(client);
// now we can run migrations with drizzle
await migrate(db, {
migrationsFolder: join(process.cwd(), 'src', 'drizzle'),
});
logger.info('migrations complete');
}