Merge branch 'main' into next
This commit is contained in:
148 files changed
+25805
-7567
No files matched your search
@@ -0,0 +1,17 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { searchDuckDuckGoBangsAsync } from "../../services/duckduckgo-bangs";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const bangsRouter = createTRPCRouter({
|
||||
search: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
query: z.string(),
|
||||
limit: z.number().int().min(1).max(50).default(20),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
return await searchDuckDuckGoBangsAsync({ query: input.query, limit: input.limit });
|
||||
}),
|
||||
});
|
||||
@@ -138,6 +138,7 @@ const getContainerOrThrowAsync = async (id: string) => {
|
||||
interface DockerContainer {
|
||||
name: string;
|
||||
id: string;
|
||||
host: string;
|
||||
state: ContainerState;
|
||||
image: string;
|
||||
ports: Port[] | undefined;
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
userChangePasswordApiSchema,
|
||||
userChangeSearchPreferencesSchema,
|
||||
userCreateSchema,
|
||||
userDdgBangsSchema,
|
||||
userEditProfileSchema,
|
||||
userFirstDayOfWeekSchema,
|
||||
userInitSchema,
|
||||
@@ -110,7 +111,7 @@ export const userRouter = createTRPCRouter({
|
||||
const queryResult = (await trx.delete(schema.invites).where(inviteWhere)) as
|
||||
| QueryResult
|
||||
| MySqlRawQueryResult;
|
||||
let count = 0;
|
||||
let count: number;
|
||||
if (Array.isArray(queryResult)) {
|
||||
count = queryResult[0].affectedRows;
|
||||
} else {
|
||||
@@ -276,6 +277,7 @@ export const userRouter = createTRPCRouter({
|
||||
pingIconsEnabled: true,
|
||||
defaultSearchEngineId: true,
|
||||
openSearchInNewTab: true,
|
||||
ddgBangs: true,
|
||||
}),
|
||||
)
|
||||
.meta({ openapi: { method: "GET", path: "/api/users/{userId}", tags: ["users"], protect: true } })
|
||||
@@ -301,6 +303,7 @@ export const userRouter = createTRPCRouter({
|
||||
pingIconsEnabled: true,
|
||||
defaultSearchEngineId: true,
|
||||
openSearchInNewTab: true,
|
||||
ddgBangs: true,
|
||||
},
|
||||
where: eq(users.id, input.userId),
|
||||
});
|
||||
@@ -541,6 +544,33 @@ export const userRouter = createTRPCRouter({
|
||||
})
|
||||
.where(eq(users.id, ctx.session.user.id));
|
||||
}),
|
||||
changeDdgBangs: protectedProcedure
|
||||
.input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema)))
|
||||
.output(z.void())
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "PATCH",
|
||||
path: "/api/users/ddg-bangs",
|
||||
tags: ["users"],
|
||||
protect: true,
|
||||
deprecated: true,
|
||||
},
|
||||
})
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
ddgBangs: input.ddgBangs,
|
||||
})
|
||||
.where(eq(users.id, input.id));
|
||||
}),
|
||||
changeFirstDayOfWeek: protectedProcedure
|
||||
.input(convertIntersectionToZodObject(userFirstDayOfWeekSchema.and(byIdSchema)))
|
||||
.output(z.void())
|
||||
|
||||
@@ -15,10 +15,13 @@ export const changeSearchPreferencesInputSchema = userChangeSearchPreferencesSch
|
||||
export const changeSearchPreferencesAsync = async (
|
||||
db: Database,
|
||||
session: Session,
|
||||
input: Modify<z.infer<typeof changeSearchPreferencesInputSchema>, { openInNewTab: boolean | undefined }>,
|
||||
input: Modify<
|
||||
z.infer<typeof changeSearchPreferencesInputSchema>,
|
||||
{ openInNewTab: boolean | undefined; ddgBangsEnabled: boolean | undefined }
|
||||
>,
|
||||
) => {
|
||||
const user = session.user;
|
||||
// Only admins can change other users passwords
|
||||
// Only admins can change other users search preferences
|
||||
if (!user.permissions.includes("admin") && user.id !== input.userId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
@@ -45,6 +48,7 @@ export const changeSearchPreferencesAsync = async (
|
||||
.set({
|
||||
defaultSearchEngineId: input.defaultSearchEngineId,
|
||||
openSearchInNewTab: input.openInNewTab,
|
||||
ddgBangs: input.ddgBangsEnabled,
|
||||
})
|
||||
.where(eq(users.id, input.userId));
|
||||
};
|
||||
@@ -2,6 +2,8 @@ import { observable } from "@trpc/server/observable";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import type { Integration } from "@homarr/db/schema";
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
@@ -12,6 +14,8 @@ import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const logger = createLogger({ module: "calendarRouter" });
|
||||
|
||||
export const calendarRouter = createTRPCRouter({
|
||||
findAllEvents: publicProcedure
|
||||
.input(
|
||||
@@ -24,7 +28,7 @@ export const calendarRouter = createTRPCRouter({
|
||||
)
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await Promise.all(
|
||||
const settled = await Promise.allSettled(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const { integrationIds: _integrationIds, ...handlerInput } = input;
|
||||
const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput);
|
||||
@@ -40,6 +44,24 @@ export const calendarRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return settled.flatMap((result, index) => {
|
||||
if (result.status === "fulfilled") {
|
||||
return [result.value];
|
||||
}
|
||||
const integration = ctx.integrations[index];
|
||||
logger.warn(
|
||||
new ErrorWithMetadata(
|
||||
"Calendar integration request failed; skipping events for this integration",
|
||||
{
|
||||
integrationId: integration?.id,
|
||||
integrationKind: integration?.kind,
|
||||
},
|
||||
{ cause: result.reason },
|
||||
),
|
||||
);
|
||||
return [];
|
||||
});
|
||||
}),
|
||||
subscribeToEvents: publicProcedure
|
||||
.input(
|
||||
|
||||
@@ -30,4 +30,5 @@ export const widgetRouter = createTRPCRouter({
|
||||
timetable: lazy(() => import("./timetable").then((mod) => mod.timetableRouter)),
|
||||
tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)),
|
||||
speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)),
|
||||
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import {
|
||||
umamiActiveVisitorsRequestHandler,
|
||||
umamiEventNamesRequestHandler,
|
||||
umamiMultiEventRequestHandler,
|
||||
umamiRequestHandler,
|
||||
umamiTopPagesRequestHandler,
|
||||
umamiTopReferrersRequestHandler,
|
||||
} from "@homarr/request-handler/umami";
|
||||
|
||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const logger = createLogger({ module: "umami-router" });
|
||||
|
||||
export const umamiRouter = createTRPCRouter({
|
||||
getWebsites: publicProcedure.concat(createOneIntegrationMiddleware("query", "umami")).query(async ({ ctx }) => {
|
||||
try {
|
||||
const instance = await createIntegrationAsync(ctx.integration);
|
||||
return await instance.getWebsitesAsync();
|
||||
} catch (error) {
|
||||
logger.warn(new Error("Failed to load websites", { cause: error }));
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
|
||||
getVisitorStats: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
websiteId: z.string(),
|
||||
timeFrame: z.string(),
|
||||
eventName: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.concat(createManyIntegrationMiddleware("query", "umami"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const results = await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = umamiRequestHandler.handler(integration, {
|
||||
websiteId: input.websiteId,
|
||||
timeFrame: input.timeFrame,
|
||||
eventName: input.eventName,
|
||||
});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
visitorStats: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return results;
|
||||
}),
|
||||
|
||||
getEventNames: publicProcedure
|
||||
.input(z.object({ websiteId: z.string() }))
|
||||
.concat(createOneIntegrationMiddleware("query", "umami"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
try {
|
||||
const innerHandler = umamiEventNamesRequestHandler.handler(ctx.integration, { websiteId: input.websiteId });
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
new ErrorWithMetadata("Failed to load event names", { websiteId: input.websiteId }, { cause: error }),
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
|
||||
getTopPages: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
websiteId: z.string(),
|
||||
timeFrame: z.string(),
|
||||
limit: z.number().int().min(1).max(500),
|
||||
}),
|
||||
)
|
||||
.concat(createOneIntegrationMiddleware("query", "umami"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
try {
|
||||
const innerHandler = umamiTopPagesRequestHandler.handler(ctx.integration, {
|
||||
websiteId: input.websiteId,
|
||||
timeFrame: input.timeFrame,
|
||||
limit: input.limit,
|
||||
});
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
new ErrorWithMetadata(
|
||||
"Failed to load top pages",
|
||||
{ websiteId: input.websiteId, timeFrame: input.timeFrame, limit: input.limit },
|
||||
{ cause: error },
|
||||
),
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
|
||||
getTopReferrers: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
websiteId: z.string(),
|
||||
timeFrame: z.string(),
|
||||
limit: z.number().int().min(1).max(500),
|
||||
}),
|
||||
)
|
||||
.concat(createOneIntegrationMiddleware("query", "umami"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
try {
|
||||
const innerHandler = umamiTopReferrersRequestHandler.handler(ctx.integration, {
|
||||
websiteId: input.websiteId,
|
||||
timeFrame: input.timeFrame,
|
||||
limit: input.limit,
|
||||
});
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
new ErrorWithMetadata(
|
||||
"Failed to load top referrers",
|
||||
{ websiteId: input.websiteId, timeFrame: input.timeFrame, limit: input.limit },
|
||||
{ cause: error },
|
||||
),
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
|
||||
getMultiEventTimeSeries: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
websiteId: z.string(),
|
||||
timeFrame: z.string(),
|
||||
eventNames: z.array(z.string()),
|
||||
}),
|
||||
)
|
||||
.concat(createOneIntegrationMiddleware("query", "umami"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
try {
|
||||
const sortedNames = [...input.eventNames].sort();
|
||||
const innerHandler = umamiMultiEventRequestHandler.handler(ctx.integration, {
|
||||
websiteId: input.websiteId,
|
||||
timeFrame: input.timeFrame,
|
||||
eventNames: sortedNames,
|
||||
});
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
new ErrorWithMetadata(
|
||||
"Failed to load multi-event time series",
|
||||
{ websiteId: input.websiteId, timeFrame: input.timeFrame, eventNames: JSON.stringify(input.eventNames) },
|
||||
{ cause: error },
|
||||
),
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
|
||||
getActiveVisitors: publicProcedure
|
||||
.input(z.object({ websiteId: z.string() }))
|
||||
.concat(createOneIntegrationMiddleware("query", "umami"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
try {
|
||||
const innerHandler = umamiActiveVisitorsRequestHandler.handler(ctx.integration, {
|
||||
websiteId: input.websiteId,
|
||||
});
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
new ErrorWithMetadata("Failed to load active visitors", { websiteId: input.websiteId }, { cause: error }),
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}),
|
||||
|
||||
subscribeActiveVisitors: publicProcedure
|
||||
.input(z.object({ websiteId: z.string() }))
|
||||
.concat(createOneIntegrationMiddleware("query", "umami"))
|
||||
.subscription(({ ctx, input }) => {
|
||||
return observable<number>((emit) => {
|
||||
const innerHandler = umamiActiveVisitorsRequestHandler.handler(ctx.integration, {
|
||||
websiteId: input.websiteId,
|
||||
});
|
||||
const unsubscribe = innerHandler.subscribe((count) => {
|
||||
emit.next(count);
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user