mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-26 16:30:57 +01:00
feat: add media management (#1337)
* feat: add media management * feat: add missing page search item * fix: medias should be hidden for anonymous users * chore: rename show-all to include-from-all-users * fix: inconsistent table column for creator-id of media * fix: schema check not working because of custom type for blob in mysql * chore: temporarily remove migrations * chore: readd removed migrations
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
IconLayoutDashboard,
|
||||
IconLogs,
|
||||
IconMailForward,
|
||||
IconPhoto,
|
||||
IconPlug,
|
||||
IconQuestionMark,
|
||||
IconReport,
|
||||
@@ -62,6 +63,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
href: "/manage/search-engines",
|
||||
label: t("items.searchEngies"),
|
||||
},
|
||||
{
|
||||
icon: IconPhoto,
|
||||
href: "/manage/medias",
|
||||
label: t("items.medias"),
|
||||
hidden: !session,
|
||||
},
|
||||
{
|
||||
icon: IconUser,
|
||||
label: t("items.users.label"),
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, CopyButton, Tooltip } from "@mantine/core";
|
||||
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface CopyMediaProps {
|
||||
media: RouterOutputs["media"]["getPaginated"]["items"][number];
|
||||
}
|
||||
|
||||
export const CopyMedia = ({ media }: CopyMediaProps) => {
|
||||
const t = useI18n();
|
||||
|
||||
const url =
|
||||
typeof window !== "undefined"
|
||||
? `${window.location.protocol}://${window.location.hostname}:${window.location.port}/api/user-medias/${media.id}`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<CopyButton value={url}>
|
||||
{({ copy, copied }) => (
|
||||
<Tooltip label={t("media.action.copy.label")} openDelay={500}>
|
||||
<ActionIcon onClick={copy} color={copied ? "teal" : "gray"} variant="subtle">
|
||||
{copied ? <IconCheck size={16} stroke={1.5} /> : <IconCopy size={16} stroke={1.5} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface DeleteMediaProps {
|
||||
media: RouterOutputs["media"]["getPaginated"]["items"][number];
|
||||
}
|
||||
|
||||
export const DeleteMedia = ({ media }: DeleteMediaProps) => {
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const t = useI18n();
|
||||
const { mutateAsync, isPending } = clientApi.media.deleteMedia.useMutation();
|
||||
|
||||
const onClick = () => {
|
||||
openConfirmModal({
|
||||
title: t("media.action.delete.label"),
|
||||
children: t("media.action.delete.description", { name: <b>{media.name}</b> }),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
onConfirm: async () => {
|
||||
await mutateAsync({ id: media.id });
|
||||
await revalidatePathActionAsync("/manage/medias");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip label={t("media.action.delete.label")} openDelay={500}>
|
||||
<ActionIcon color="red" variant="subtle" onClick={onClick} loading={isPending}>
|
||||
<IconTrash color="red" size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { ChangeEvent } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { Switch } from "@mantine/core";
|
||||
import type { SwitchProps } from "@mantine/core";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
type ShowAllSwitchProps = Pick<SwitchProps, "defaultChecked">;
|
||||
|
||||
export const IncludeFromAllUsersSwitch = ({ defaultChecked }: ShowAllSwitchProps) => {
|
||||
const router = useRouter();
|
||||
const pathName = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [checked, setChecked] = useState(defaultChecked);
|
||||
const t = useI18n();
|
||||
|
||||
const onChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setChecked(event.target.checked);
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("includeFromAllUsers", event.target.checked.toString());
|
||||
if (params.has("page")) params.set("page", "1"); // Reset page to 1
|
||||
router.replace(`${pathName}?${params.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Switch
|
||||
defaultChecked={defaultChecked}
|
||||
checked={checked}
|
||||
label={t("management.page.media.includeFromAllUsers")}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { Button, FileButton } from "@mantine/core";
|
||||
import { IconUpload } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { supportedMediaUploadFormats } from "@homarr/validation";
|
||||
|
||||
export const UploadMedia = () => {
|
||||
const t = useI18n();
|
||||
const { mutateAsync, isPending } = clientApi.media.uploadMedia.useMutation();
|
||||
|
||||
const handleFileUploadAsync = async (file: File | null) => {
|
||||
if (!file) return;
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
await mutateAsync(formData, {
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
message: t("media.action.upload.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
message: t("media.action.upload.notification.error.message"),
|
||||
});
|
||||
},
|
||||
async onSettled() {
|
||||
await revalidatePathActionAsync("/manage/medias");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<FileButton onChange={handleFileUploadAsync} accept={supportedMediaUploadFormats.join(",")}>
|
||||
{({ onClick }) => (
|
||||
<Button onClick={onClick} loading={isPending} rightSection={<IconUpload size={16} stroke={1.5} />}>
|
||||
{t("media.action.upload.label")}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
);
|
||||
};
|
||||
128
apps/nextjs/src/app/[locale]/manage/medias/page.tsx
Normal file
128
apps/nextjs/src/app/[locale]/manage/medias/page.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Title } from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { SearchInput, TablePagination, UserAvatar } from "@homarr/ui";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { CopyMedia } from "./_actions/copy-media";
|
||||
import { DeleteMedia } from "./_actions/delete-media";
|
||||
import { IncludeFromAllUsersSwitch } from "./_actions/show-all";
|
||||
import { UploadMedia } from "./_actions/upload-media";
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
search: z.string().optional(),
|
||||
includeFromAllUsers: z
|
||||
.string()
|
||||
.regex(/true|false/)
|
||||
.catch("false")
|
||||
.transform((value) => value === "true"),
|
||||
pageSize: z.string().regex(/\d+/).transform(Number).catch(10),
|
||||
page: z.string().regex(/\d+/).transform(Number).catch(1),
|
||||
});
|
||||
|
||||
type SearchParamsSchemaInputFromSchema<TSchema extends Record<string, unknown>> = Partial<{
|
||||
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
|
||||
}>;
|
||||
|
||||
interface MediaListPageProps {
|
||||
searchParams: SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>;
|
||||
}
|
||||
|
||||
export default async function GroupsListPage(props: MediaListPageProps) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const t = await getI18n();
|
||||
const searchParams = searchParamsSchema.parse(props.searchParams);
|
||||
const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
|
||||
const isAdmin = session.user.permissions.includes("admin");
|
||||
|
||||
return (
|
||||
<ManageContainer size="xl">
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title>{t("media.plural")}</Title>
|
||||
<Group justify="space-between">
|
||||
<Group>
|
||||
<SearchInput placeholder={`${t("media.search")}...`} defaultValue={searchParams.search} />
|
||||
{isAdmin && <IncludeFromAllUsersSwitch defaultChecked={searchParams.includeFromAllUsers} />}
|
||||
</Group>
|
||||
|
||||
<UploadMedia />
|
||||
</Group>
|
||||
<Table striped highlightOnHover>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh></TableTh>
|
||||
<TableTh>{t("media.field.name")}</TableTh>
|
||||
<TableTh>{t("media.field.size")}</TableTh>
|
||||
<TableTh>{t("media.field.creator")}</TableTh>
|
||||
<TableTh></TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{medias.map((media) => (
|
||||
<Row key={media.id} media={media} />
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
|
||||
<Group justify="end">
|
||||
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||
</Group>
|
||||
</Stack>
|
||||
</ManageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
interface RowProps {
|
||||
media: RouterOutputs["media"]["getPaginated"]["items"][number];
|
||||
}
|
||||
|
||||
const Row = ({ media }: RowProps) => {
|
||||
return (
|
||||
<TableTr>
|
||||
<TableTd w={64}>
|
||||
<Image
|
||||
src={`/api/user-medias/${media.id}`}
|
||||
alt={media.name}
|
||||
width={64}
|
||||
height={64}
|
||||
style={{ objectFit: "contain" }}
|
||||
/>
|
||||
</TableTd>
|
||||
<TableTd>{media.name}</TableTd>
|
||||
<TableTd>{humanFileSize(media.size)}</TableTd>
|
||||
<TableTd>
|
||||
{media.creator ? (
|
||||
<Group gap="sm">
|
||||
<UserAvatar user={media.creator} size="sm" />
|
||||
<Anchor component={Link} href={`/manage/users/${media.creator.id}/general`} size="sm">
|
||||
{media.creator.name}
|
||||
</Anchor>
|
||||
</Group>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</TableTd>
|
||||
<TableTd w={64}>
|
||||
<Group wrap="nowrap" gap="xs">
|
||||
<CopyMedia media={media} />
|
||||
<DeleteMedia media={media} />
|
||||
</Group>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
);
|
||||
};
|
||||
29
apps/nextjs/src/app/api/user-medias/[id]/route.ts
Normal file
29
apps/nextjs/src/app/api/user-medias/[id]/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { db, eq } from "@homarr/db";
|
||||
import { medias } from "@homarr/db/schema/sqlite";
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: { params: { id: string } }) {
|
||||
const image = await db.query.medias.findFirst({
|
||||
where: eq(medias.id, params.id),
|
||||
columns: {
|
||||
content: true,
|
||||
contentType: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!image) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set("Content-Type", image.contentType);
|
||||
headers.set("Content-Length", image.content.length.toString());
|
||||
|
||||
return new NextResponse(image.content, {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { integrationRouter } from "./router/integration/integration-router";
|
||||
import { inviteRouter } from "./router/invite";
|
||||
import { locationRouter } from "./router/location";
|
||||
import { logRouter } from "./router/log";
|
||||
import { mediaRouter } from "./router/medias/media-router";
|
||||
import { searchEngineRouter } from "./router/search-engine/search-engine-router";
|
||||
import { serverSettingsRouter } from "./router/serverSettings";
|
||||
import { userRouter } from "./router/user";
|
||||
@@ -33,6 +34,7 @@ export const appRouter = createTRPCRouter({
|
||||
serverSettings: serverSettingsRouter,
|
||||
cronJobs: cronJobsRouter,
|
||||
apiKeys: apiKeysRouter,
|
||||
media: mediaRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
88
packages/api/src/router/medias/media-router.ts
Normal file
88
packages/api/src/router/medias/media-router.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { and, createId, desc, eq, like } from "@homarr/db";
|
||||
import { medias } from "@homarr/db/schema/sqlite";
|
||||
import { validation, z } from "@homarr/validation";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../../trpc";
|
||||
|
||||
export const mediaRouter = createTRPCRouter({
|
||||
getPaginated: protectedProcedure
|
||||
.input(
|
||||
validation.common.paginated.and(
|
||||
z.object({ includeFromAllUsers: z.boolean().default(false), search: z.string().trim().default("") }),
|
||||
),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const includeFromAllUsers = ctx.session.user.permissions.includes("admin") && input.includeFromAllUsers;
|
||||
|
||||
const where = and(
|
||||
input.search.length >= 1 ? like(medias.name, `%${input.search}%`) : undefined,
|
||||
includeFromAllUsers ? undefined : eq(medias.creatorId, ctx.session.user.id),
|
||||
);
|
||||
const dbMedias = await ctx.db.query.medias.findMany({
|
||||
where,
|
||||
orderBy: desc(medias.createdAt),
|
||||
limit: input.pageSize,
|
||||
offset: (input.page - 1) * input.pageSize,
|
||||
columns: {
|
||||
content: false,
|
||||
},
|
||||
with: {
|
||||
creator: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const totalCount = await ctx.db.$count(medias, where);
|
||||
|
||||
return {
|
||||
items: dbMedias,
|
||||
totalCount,
|
||||
};
|
||||
}),
|
||||
uploadMedia: protectedProcedure.input(validation.media.uploadMedia).mutation(async ({ ctx, input }) => {
|
||||
const content = Buffer.from(await input.file.arrayBuffer());
|
||||
const id = createId();
|
||||
await ctx.db.insert(medias).values({
|
||||
id,
|
||||
creatorId: ctx.session.user.id,
|
||||
content,
|
||||
size: input.file.size,
|
||||
contentType: input.file.type,
|
||||
name: input.file.name,
|
||||
});
|
||||
|
||||
return id;
|
||||
}),
|
||||
deleteMedia: protectedProcedure.input(validation.common.byId).mutation(async ({ ctx, input }) => {
|
||||
const dbMedia = await ctx.db.query.medias.findFirst({
|
||||
where: eq(medias.id, input.id),
|
||||
columns: {
|
||||
creatorId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!dbMedia) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Media not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Only allow admins and the creator of the media to delete it
|
||||
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== dbMedia.creatorId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have permission to delete this media",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.delete(medias).where(eq(medias.id, input.id));
|
||||
}),
|
||||
});
|
||||
12
packages/db/migrations/mysql/0014_bizarre_red_shift.sql
Normal file
12
packages/db/migrations/mysql/0014_bizarre_red_shift.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE `media` (
|
||||
`id` varchar(64) NOT NULL,
|
||||
`name` varchar(512) NOT NULL,
|
||||
`content` BLOB NOT NULL,
|
||||
`content_type` text NOT NULL,
|
||||
`size` int NOT NULL,
|
||||
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||
`creator_id` varchar(64),
|
||||
CONSTRAINT `media_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `media` ADD CONSTRAINT `media_creator_id_user_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;
|
||||
1602
packages/db/migrations/mysql/meta/0014_snapshot.json
Normal file
1602
packages/db/migrations/mysql/meta/0014_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -99,6 +99,13 @@
|
||||
"when": 1729369383739,
|
||||
"tag": "0013_youthful_vulture",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "5",
|
||||
"when": 1729524382483,
|
||||
"tag": "0014_bizarre_red_shift",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
10
packages/db/migrations/sqlite/0014_colorful_cargill.sql
Normal file
10
packages/db/migrations/sqlite/0014_colorful_cargill.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE `media` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`content` blob NOT NULL,
|
||||
`content_type` text NOT NULL,
|
||||
`size` integer NOT NULL,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`creator_id` text,
|
||||
FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
1531
packages/db/migrations/sqlite/meta/0014_snapshot.json
Normal file
1531
packages/db/migrations/sqlite/meta/0014_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -99,6 +99,13 @@
|
||||
"when": 1729369389386,
|
||||
"tag": "0013_faithful_hex",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "6",
|
||||
"when": 1729524387583,
|
||||
"tag": "0014_colorful_cargill",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,18 @@ import type { AdapterAccount } from "@auth/core/adapters";
|
||||
import type { DayOfWeek } from "@mantine/dates";
|
||||
import { relations } from "drizzle-orm";
|
||||
import type { AnyMySqlColumn } from "drizzle-orm/mysql-core";
|
||||
import { boolean, index, int, mysqlTable, primaryKey, text, timestamp, tinyint, varchar } from "drizzle-orm/mysql-core";
|
||||
import {
|
||||
boolean,
|
||||
customType,
|
||||
index,
|
||||
int,
|
||||
mysqlTable,
|
||||
primaryKey,
|
||||
text,
|
||||
timestamp,
|
||||
tinyint,
|
||||
varchar,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
|
||||
import type {
|
||||
BackgroundImageAttachment,
|
||||
@@ -20,6 +31,12 @@ import type {
|
||||
} from "@homarr/definitions";
|
||||
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
|
||||
|
||||
const customBlob = customType<{ data: Buffer }>({
|
||||
dataType() {
|
||||
return "BLOB";
|
||||
},
|
||||
});
|
||||
|
||||
export const apiKeys = mysqlTable("apiKey", {
|
||||
id: varchar("id", { length: 64 }).notNull().primaryKey(),
|
||||
apiKey: text("apiKey").notNull(),
|
||||
@@ -142,6 +159,16 @@ export const invites = mysqlTable("invite", {
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
});
|
||||
|
||||
export const medias = mysqlTable("media", {
|
||||
id: varchar("id", { length: 64 }).notNull().primaryKey(),
|
||||
name: varchar("name", { length: 512 }).notNull(),
|
||||
content: customBlob("content").notNull(),
|
||||
contentType: text("content_type").notNull(),
|
||||
size: int("size").notNull(),
|
||||
createdAt: timestamp("created_at", { mode: "date" }).notNull().defaultNow(),
|
||||
creatorId: varchar("creator_id", { length: 64 }).references(() => users.id, { onDelete: "set null" }),
|
||||
});
|
||||
|
||||
export const integrations = mysqlTable(
|
||||
"integration",
|
||||
{
|
||||
@@ -387,6 +414,13 @@ export const userRelations = relations(users, ({ many }) => ({
|
||||
invites: many(invites),
|
||||
}));
|
||||
|
||||
export const mediaRelations = relations(medias, ({ one }) => ({
|
||||
creator: one(users, {
|
||||
fields: [medias.creatorId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const iconRelations = relations(icons, ({ one }) => ({
|
||||
repository: one(iconRepositories, {
|
||||
fields: [icons.iconRepositoryId],
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { AdapterAccount } from "@auth/core/adapters";
|
||||
import type { DayOfWeek } from "@mantine/dates";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import { relations } from "drizzle-orm";
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core";
|
||||
import { index, int, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import { blob, index, int, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
|
||||
import type {
|
||||
@@ -145,6 +145,18 @@ export const invites = sqliteTable("invite", {
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
});
|
||||
|
||||
export const medias = sqliteTable("media", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
content: blob("content", { mode: "buffer" }).$type<Buffer>().notNull(),
|
||||
contentType: text("content_type").notNull(),
|
||||
size: int("size").notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
creatorId: text("creator_id").references(() => users.id, { onDelete: "set null" }),
|
||||
});
|
||||
|
||||
export const integrations = sqliteTable(
|
||||
"integration",
|
||||
{
|
||||
@@ -387,6 +399,14 @@ export const userRelations = relations(users, ({ many }) => ({
|
||||
groups: many(groupMembers),
|
||||
ownedGroups: many(groups),
|
||||
invites: many(invites),
|
||||
medias: many(medias),
|
||||
}));
|
||||
|
||||
export const mediaRelations = relations(medias, ({ one }) => ({
|
||||
creator: one(users, {
|
||||
fields: [medias.creatorId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const iconRelations = relations(icons, ({ one }) => ({
|
||||
|
||||
@@ -9,11 +9,35 @@ import { objectEntries } from "@homarr/common";
|
||||
import * as mysqlSchema from "../schema/mysql";
|
||||
import * as sqliteSchema from "../schema/sqlite";
|
||||
|
||||
// We need the following two types as there is currently no support for Buffer in mysql and
|
||||
// so we use a custom type which results in the config beeing different
|
||||
type FixedMysqlConfig = {
|
||||
[key in keyof MysqlConfig]: {
|
||||
[column in keyof MysqlConfig[key]]: {
|
||||
[property in Exclude<keyof MysqlConfig[key][column], "dataType" | "data">]: MysqlConfig[key][column][property];
|
||||
} & {
|
||||
dataType: MysqlConfig[key][column]["data"] extends Buffer ? "buffer" : MysqlConfig[key][column]["dataType"];
|
||||
data: MysqlConfig[key][column]["data"] extends Buffer ? Buffer : MysqlConfig[key][column]["data"];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type FixedSqliteConfig = {
|
||||
[key in keyof SqliteConfig]: {
|
||||
[column in keyof SqliteConfig[key]]: {
|
||||
[property in Exclude<keyof SqliteConfig[key][column], "dataType" | "data">]: SqliteConfig[key][column][property];
|
||||
} & {
|
||||
dataType: SqliteConfig[key][column]["dataType"] extends Buffer ? "buffer" : SqliteConfig[key][column]["dataType"];
|
||||
data: SqliteConfig[key][column]["data"] extends Buffer ? Buffer : SqliteConfig[key][column]["data"];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
test("schemas should match", () => {
|
||||
expectTypeOf<SqliteTables>().toEqualTypeOf<MysqlTables>();
|
||||
expectTypeOf<MysqlTables>().toEqualTypeOf<SqliteTables>();
|
||||
expectTypeOf<SqliteConfig>().toEqualTypeOf<MysqlConfig>();
|
||||
expectTypeOf<MysqlConfig>().toEqualTypeOf<SqliteConfig>();
|
||||
expectTypeOf<FixedSqliteConfig>().toEqualTypeOf<FixedMysqlConfig>();
|
||||
expectTypeOf<FixedMysqlConfig>().toEqualTypeOf<FixedSqliteConfig>();
|
||||
|
||||
objectEntries(sqliteSchema).forEach(([tableName, sqliteTable]) => {
|
||||
Object.entries(sqliteTable).forEach(([columnName, sqliteColumn]: [string, object]) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
IconLayoutDashboard,
|
||||
IconLogs,
|
||||
IconMailForward,
|
||||
IconPhoto,
|
||||
IconPlug,
|
||||
IconReport,
|
||||
IconSearch,
|
||||
@@ -89,6 +90,12 @@ export const pagesSearchGroup = createGroup<{
|
||||
name: t("manageSearchEngine.label"),
|
||||
hidden: !session,
|
||||
},
|
||||
{
|
||||
icon: IconPhoto,
|
||||
path: "/manage/medias",
|
||||
name: t("manageMedia.label"),
|
||||
hidden: !session,
|
||||
},
|
||||
{
|
||||
icon: IconUsers,
|
||||
path: "/manage/users",
|
||||
|
||||
@@ -552,6 +552,44 @@ export default {
|
||||
full: "Full integration access",
|
||||
},
|
||||
},
|
||||
media: {
|
||||
plural: "Medias",
|
||||
search: "Find a media",
|
||||
field: {
|
||||
name: "Name",
|
||||
size: "Size",
|
||||
creator: "Creator",
|
||||
},
|
||||
action: {
|
||||
upload: {
|
||||
label: "Upload media",
|
||||
file: "Select file",
|
||||
notification: {
|
||||
success: {
|
||||
message: "The media was successfully uploaded",
|
||||
},
|
||||
error: {
|
||||
message: "The media could not be uploaded",
|
||||
},
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
label: "Delete media",
|
||||
description: "Are you sure you want to delete the media {name}?",
|
||||
notification: {
|
||||
success: {
|
||||
message: "The media was successfully deleted",
|
||||
},
|
||||
error: {
|
||||
message: "The media could not be deleted",
|
||||
},
|
||||
},
|
||||
},
|
||||
copy: {
|
||||
label: "Copy URL",
|
||||
},
|
||||
},
|
||||
},
|
||||
common: {
|
||||
// Either "ltr" or "rtl"
|
||||
direction: "ltr",
|
||||
@@ -1644,6 +1682,7 @@ export default {
|
||||
apps: "Apps",
|
||||
integrations: "Integrations",
|
||||
searchEngies: "Search engines",
|
||||
medias: "Medias",
|
||||
users: {
|
||||
label: "Users",
|
||||
items: {
|
||||
@@ -1732,6 +1771,9 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
media: {
|
||||
includeFromAllUsers: "Include media from all users",
|
||||
},
|
||||
user: {
|
||||
back: "Back to users",
|
||||
fieldsDisabledExternalProvider:
|
||||
@@ -2138,6 +2180,9 @@ export default {
|
||||
label: "Edit",
|
||||
},
|
||||
},
|
||||
medias: {
|
||||
label: "Medias",
|
||||
},
|
||||
apps: {
|
||||
label: "Apps",
|
||||
new: {
|
||||
@@ -2359,6 +2404,9 @@ export default {
|
||||
manageSearchEngine: {
|
||||
label: "Manage search engines",
|
||||
},
|
||||
manageMedia: {
|
||||
label: "Manage medias",
|
||||
},
|
||||
manageUser: {
|
||||
label: "Manage users",
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import { groupSchemas } from "./group";
|
||||
import { iconsSchemas } from "./icons";
|
||||
import { integrationSchemas } from "./integration";
|
||||
import { locationSchemas } from "./location";
|
||||
import { mediaSchemas } from "./media";
|
||||
import { searchEngineSchemas } from "./search-engine";
|
||||
import { userSchemas } from "./user";
|
||||
import { widgetSchemas } from "./widgets";
|
||||
@@ -19,6 +20,7 @@ export const validation = {
|
||||
location: locationSchemas,
|
||||
icons: iconsSchemas,
|
||||
searchEngine: searchEngineSchemas,
|
||||
media: mediaSchemas,
|
||||
common: commonSchemas,
|
||||
};
|
||||
|
||||
@@ -32,3 +34,4 @@ export {
|
||||
type BoardItemIntegration,
|
||||
} from "./shared";
|
||||
export { passwordRequirements } from "./user";
|
||||
export { supportedMediaUploadFormats } from "./media";
|
||||
|
||||
44
packages/validation/src/media.ts
Normal file
44
packages/validation/src/media.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { z } from "zod";
|
||||
import { zfd } from "zod-form-data";
|
||||
|
||||
import { createCustomErrorParams } from "./form/i18n";
|
||||
|
||||
export const supportedMediaUploadFormats = ["image/png", "image/jpeg", "image/webp", "image/gif", "image/svg+xml"];
|
||||
|
||||
export const uploadMediaSchema = zfd.formData({
|
||||
file: zfd.file().superRefine((value: File | null, context: z.RefinementCtx) => {
|
||||
if (!value) {
|
||||
return context.addIssue({
|
||||
code: "invalid_type",
|
||||
expected: "object",
|
||||
received: "null",
|
||||
});
|
||||
}
|
||||
|
||||
if (!supportedMediaUploadFormats.includes(value.type)) {
|
||||
return context.addIssue({
|
||||
code: "custom",
|
||||
params: createCustomErrorParams({
|
||||
key: "invalidFileType",
|
||||
params: { expected: `one of ${supportedMediaUploadFormats.join(", ")}` },
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (value.size > 1024 * 1024 * 32) {
|
||||
return context.addIssue({
|
||||
code: "custom",
|
||||
params: createCustomErrorParams({
|
||||
key: "fileTooLarge",
|
||||
params: { maxSize: "32 MB" },
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}),
|
||||
});
|
||||
|
||||
export const mediaSchemas = {
|
||||
uploadMedia: uploadMediaSchema,
|
||||
};
|
||||
Reference in New Issue
Block a user