Merge branch 'main' into next
This commit is contained in:
148 files changed
+25805
-7567
No files matched your search
@@ -26,6 +26,7 @@ export const appRouter = createTRPCRouter({
|
||||
media: lazy(() => import("./router/medias/media-router").then((mod) => mod.mediaRouter)),
|
||||
updateChecker: lazy(() => import("./router/update-checker").then((mod) => mod.updateCheckerRouter)),
|
||||
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
|
||||
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
|
||||
info: lazy(() => import("./router/info").then((mod) => mod.infoRouter)),
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { duckDuckGoBangsRequestHandler } from "@homarr/request-handler/duckduckgo-bangs";
|
||||
import type { DuckDuckGoBang } from "@homarr/request-handler/duckduckgo-bangs";
|
||||
|
||||
// DuckDuckGo bang keys are intentionally short:
|
||||
// - `t`: token (e.g. "yt"), `s`: display name, `u`: URL template (contains `{{{s}}}`)
|
||||
// - `d`: domain, `c`: category, `sc`: subcategory, `r`: rank (optional)
|
||||
|
||||
const normalizeBangToken = (token: string) => token.toLowerCase().trim();
|
||||
|
||||
/**
|
||||
* Binary search to find the first index where bang.t >= tokenPrefix.
|
||||
* This is O(log n) vs O(n) for findIndex, which matters because DuckDuckGo
|
||||
* has ~13,000+ bangs. Combined with the pre-sorted data, this allows
|
||||
* efficient prefix matching by finding the start position and iterating
|
||||
* only through consecutive matches.
|
||||
*/
|
||||
const lowerBound = (arr: DuckDuckGoBang[], tokenPrefix: string) => {
|
||||
let low = 0;
|
||||
let high = arr.length;
|
||||
while (low < high) {
|
||||
const mid = (low + high) >> 1;
|
||||
const midBang = arr[mid];
|
||||
// Must use the same ordering as the source list sort (localeCompare),
|
||||
// otherwise binary search can miss tokens with symbols like "&" or "_".
|
||||
if (!midBang || midBang.t.localeCompare(tokenPrefix) >= 0) {
|
||||
high = mid;
|
||||
continue;
|
||||
}
|
||||
|
||||
low = mid + 1;
|
||||
}
|
||||
return low;
|
||||
};
|
||||
|
||||
export const searchDuckDuckGoBangsAsync = async (input: {
|
||||
query: string;
|
||||
limit: number;
|
||||
}): Promise<DuckDuckGoBang[]> => {
|
||||
const queryTokenPrefix = normalizeBangToken(input.query);
|
||||
if (!queryTokenPrefix) return [];
|
||||
|
||||
const { data: allBangs } = await duckDuckGoBangsRequestHandler.handler({}).getCachedOrUpdatedDataAsync({});
|
||||
const startIndex = lowerBound(allBangs, queryTokenPrefix);
|
||||
const matches: DuckDuckGoBang[] = [];
|
||||
|
||||
for (let index = startIndex; index < allBangs.length; index++) {
|
||||
const bang = allBangs[index];
|
||||
if (!bang) break;
|
||||
if (!bang.t.startsWith(queryTokenPrefix)) break;
|
||||
matches.push(bang);
|
||||
if (matches.length >= input.limit) break;
|
||||
}
|
||||
|
||||
return matches;
|
||||
};
|
||||
Reference in new issue
Block a user