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:
Meier Lukas
2024-10-26 22:45:32 +02:00
committed by GitHub
parent f8c21f6000
commit db198c6dab
22 changed files with 3762 additions and 5 deletions

View File

@@ -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

View 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));
}),
});

View 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;

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
]
}

View 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
);

File diff suppressed because it is too large Load Diff

View File

@@ -99,6 +99,13 @@
"when": 1729369389386,
"tag": "0013_faithful_hex",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1729524387583,
"tag": "0014_colorful_cargill",
"breakpoints": true
}
]
}

View File

@@ -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],

View File

@@ -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 }) => ({

View File

@@ -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]) => {

View File

@@ -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",

View File

@@ -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",
},

View File

@@ -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";

View 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,
};