refactor widget-only integrations
This commit is contained in:
122 files changed
+2259
-3016
No files matched your search
@@ -1,386 +0,0 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import WebSocket from "ws";
|
||||
import type { RawData } from "ws";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import {
|
||||
bandwidthMessageSchema,
|
||||
itemStatusMessageSchema,
|
||||
pipelineStartItemMessageSchema,
|
||||
projectRefreshMessageSchema,
|
||||
seesawFrameSchema,
|
||||
warriorBroadcastMessageSchema,
|
||||
warriorProjectSelectedMessageSchema,
|
||||
warriorStatusMessageSchema,
|
||||
} from "./archive-team-warrior-schemas";
|
||||
import type { SeesawFrame, WarriorItem, WarriorProject } from "./archive-team-warrior-schemas";
|
||||
import type { ArchiveTeamWarriorItem, ArchiveTeamWarriorStatus } from "./archive-team-warrior-types";
|
||||
|
||||
const SOCKJS_SNAPSHOT_TIMEOUT_MS = 2500;
|
||||
|
||||
interface WarriorSnapshotState {
|
||||
status: string;
|
||||
runnerStatus?: string;
|
||||
selectedProject: string | null;
|
||||
broadcastMessage: string | null;
|
||||
project: ArchiveTeamWarriorStatus["project"];
|
||||
bandwidth: ArchiveTeamWarriorStatus["bandwidth"];
|
||||
items: Map<string, ArchiveTeamWarriorItem>;
|
||||
}
|
||||
|
||||
export class ArchiveTeamWarriorIntegration extends Integration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(this.url("/index.html"), {
|
||||
headers: this.createAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) return TestConnectionError.StatusResult(response);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public async getStatusAsync(): Promise<ArchiveTeamWarriorStatus> {
|
||||
const baseUrl = this.url("/").toString().replace(/\/$/, "");
|
||||
|
||||
await fetchWithTrustedCertificatesAsync(`${baseUrl}/index.html`, {
|
||||
headers: this.createAuthHeaders(),
|
||||
});
|
||||
|
||||
return await this.readSockJsSnapshotAsync(baseUrl);
|
||||
}
|
||||
|
||||
private async readSockJsSnapshotAsync(baseUrl: string): Promise<ArchiveTeamWarriorStatus> {
|
||||
const snapshot: WarriorSnapshotState = {
|
||||
status: "Unknown",
|
||||
selectedProject: null,
|
||||
broadcastMessage: null,
|
||||
project: null,
|
||||
bandwidth: null,
|
||||
items: new Map<string, ArchiveTeamWarriorItem>(),
|
||||
};
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let isSettled = false;
|
||||
|
||||
const socket = new WebSocket(this.createSockJsWebSocketUrl(baseUrl), {
|
||||
headers: this.createAuthHeaders(),
|
||||
});
|
||||
|
||||
const finish = () => {
|
||||
if (isSettled) return;
|
||||
|
||||
isSettled = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
|
||||
socket.close();
|
||||
}
|
||||
|
||||
resolve();
|
||||
};
|
||||
|
||||
const fail = () => {
|
||||
if (isSettled) return;
|
||||
|
||||
isSettled = true;
|
||||
clearTimeout(timeout);
|
||||
reject(new Error("Failed to connect to ArchiveTeam Warrior websocket"));
|
||||
};
|
||||
|
||||
const timeout = setTimeout(finish, SOCKJS_SNAPSHOT_TIMEOUT_MS);
|
||||
|
||||
socket.on("message", (data: RawData) => {
|
||||
const frames = this.parseSockJsMessage(this.decodeWebSocketData(data));
|
||||
|
||||
for (const frame of frames) {
|
||||
this.applyFrameToSnapshot(frame, snapshot);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("error", fail);
|
||||
socket.on("close", finish);
|
||||
});
|
||||
|
||||
return this.createStatusFromSnapshot(snapshot);
|
||||
}
|
||||
|
||||
private applyFrameToSnapshot(frame: SeesawFrame, snapshot: WarriorSnapshotState) {
|
||||
switch (frame.event_name) {
|
||||
case "warrior.status":
|
||||
this.applyWarriorStatusFrame(frame, snapshot);
|
||||
break;
|
||||
|
||||
case "warrior.project_selected":
|
||||
this.applyProjectSelectedFrame(frame, snapshot);
|
||||
break;
|
||||
|
||||
case "warrior.broadcast_message":
|
||||
this.applyBroadcastMessageFrame(frame, snapshot);
|
||||
break;
|
||||
|
||||
case "runner.status":
|
||||
this.applyRunnerStatusFrame(frame, snapshot);
|
||||
break;
|
||||
|
||||
case "project.refresh":
|
||||
this.applyProjectRefreshFrame(frame, snapshot);
|
||||
break;
|
||||
|
||||
case "pipeline.start_item":
|
||||
this.applyPipelineStartItemFrame(frame, snapshot);
|
||||
break;
|
||||
|
||||
case "item.complete":
|
||||
this.applyItemStatusFrame(frame, snapshot, "completed");
|
||||
break;
|
||||
|
||||
case "item.fail":
|
||||
this.applyItemStatusFrame(frame, snapshot, "failed");
|
||||
break;
|
||||
|
||||
case "item.cancel":
|
||||
this.applyItemStatusFrame(frame, snapshot, "canceled");
|
||||
break;
|
||||
|
||||
case "bandwidth":
|
||||
this.applyBandwidthFrame(frame, snapshot);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private applyWarriorStatusFrame(frame: SeesawFrame, snapshot: WarriorSnapshotState) {
|
||||
const message = warriorStatusMessageSchema.safeParse(frame.message);
|
||||
if (!message.success) return;
|
||||
|
||||
snapshot.status = this.normalizeDisplayStatus(message.data.status);
|
||||
}
|
||||
|
||||
private applyProjectSelectedFrame(frame: SeesawFrame, snapshot: WarriorSnapshotState) {
|
||||
const message = warriorProjectSelectedMessageSchema.safeParse(frame.message);
|
||||
if (!message.success) return;
|
||||
|
||||
snapshot.selectedProject = message.data.project;
|
||||
}
|
||||
|
||||
private applyBroadcastMessageFrame(frame: SeesawFrame, snapshot: WarriorSnapshotState) {
|
||||
const message = warriorBroadcastMessageSchema.safeParse(frame.message);
|
||||
if (!message.success) return;
|
||||
|
||||
snapshot.broadcastMessage = message.data.message;
|
||||
}
|
||||
|
||||
private applyRunnerStatusFrame(frame: SeesawFrame, snapshot: WarriorSnapshotState) {
|
||||
const message = warriorStatusMessageSchema.safeParse(frame.message);
|
||||
if (!message.success) return;
|
||||
|
||||
snapshot.runnerStatus = this.normalizeDisplayStatus(message.data.status);
|
||||
}
|
||||
|
||||
private applyProjectRefreshFrame(frame: SeesawFrame, snapshot: WarriorSnapshotState) {
|
||||
const message = projectRefreshMessageSchema.safeParse(frame.message);
|
||||
if (!message.success) return;
|
||||
|
||||
snapshot.project = this.mapProject(message.data?.project);
|
||||
snapshot.runnerStatus = message.data?.status
|
||||
? this.normalizeDisplayStatus(message.data.status)
|
||||
: snapshot.runnerStatus;
|
||||
|
||||
for (const item of message.data?.items ?? []) {
|
||||
snapshot.items.set(item.id, this.mapItem(item));
|
||||
}
|
||||
}
|
||||
|
||||
private applyPipelineStartItemFrame(frame: SeesawFrame, snapshot: WarriorSnapshotState) {
|
||||
const message = pipelineStartItemMessageSchema.safeParse(frame.message);
|
||||
if (!message.success) return;
|
||||
|
||||
snapshot.items.set(message.data.item.id, this.mapItem(message.data.item));
|
||||
}
|
||||
|
||||
private applyItemStatusFrame(
|
||||
frame: SeesawFrame,
|
||||
snapshot: WarriorSnapshotState,
|
||||
status: ArchiveTeamWarriorItem["status"],
|
||||
) {
|
||||
const message = itemStatusMessageSchema.safeParse(frame.message);
|
||||
if (!message.success) return;
|
||||
|
||||
this.updateItemStatus(snapshot.items, message.data.item_id, status);
|
||||
}
|
||||
|
||||
private applyBandwidthFrame(frame: SeesawFrame, snapshot: WarriorSnapshotState) {
|
||||
const message = bandwidthMessageSchema.safeParse(frame.message);
|
||||
if (!message.success) return;
|
||||
|
||||
snapshot.bandwidth = message.data;
|
||||
}
|
||||
|
||||
private createStatusFromSnapshot(snapshot: WarriorSnapshotState): ArchiveTeamWarriorStatus {
|
||||
const itemList = Array.from(snapshot.items.values());
|
||||
|
||||
return {
|
||||
status: snapshot.status,
|
||||
runnerStatus: snapshot.runnerStatus,
|
||||
project: snapshot.project,
|
||||
selectedProject: snapshot.selectedProject,
|
||||
broadcastMessage: snapshot.broadcastMessage,
|
||||
bandwidth: snapshot.bandwidth,
|
||||
items: itemList,
|
||||
counts: {
|
||||
running: itemList.filter((item) => item.status === "running").length,
|
||||
completed: itemList.filter((item) => item.status === "completed").length,
|
||||
failed: itemList.filter((item) => item.status === "failed").length,
|
||||
canceled: itemList.filter((item) => item.status === "canceled").length,
|
||||
},
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private createAuthHeaders() {
|
||||
const authorization = this.createBasicAuthHeader();
|
||||
|
||||
return authorization ? { Authorization: authorization } : undefined;
|
||||
}
|
||||
|
||||
private createBasicAuthHeader() {
|
||||
if (!this.hasSecretValue("username") || !this.hasSecretValue("password")) return undefined;
|
||||
|
||||
const username = this.getSecretValue("username");
|
||||
const password = this.getSecretValue("password");
|
||||
const credentials = Buffer.from(`${username}:${password}`, "utf8").toString("base64");
|
||||
|
||||
return `Basic ${credentials}`;
|
||||
}
|
||||
|
||||
private decodeWebSocketData(data: RawData) {
|
||||
if (typeof data === "string") return data;
|
||||
if (Buffer.isBuffer(data)) return data.toString("utf8");
|
||||
if (Array.isArray(data)) return Buffer.concat(data).toString("utf8");
|
||||
|
||||
return Buffer.from(data).toString("utf8");
|
||||
}
|
||||
|
||||
private parseSockJsMessage(raw: string): SeesawFrame[] {
|
||||
if (raw === "o" || raw === "h" || raw.length === 0) return [];
|
||||
if (!raw.startsWith("a")) return [];
|
||||
|
||||
const rawPayload = this.parseJsonSafely(raw.slice(1));
|
||||
const payload = z.array(z.string()).safeParse(rawPayload);
|
||||
if (!payload.success) return [];
|
||||
|
||||
return payload.data.flatMap((frame) => {
|
||||
const rawFrame = this.parseJsonSafely(frame);
|
||||
const result = seesawFrameSchema.safeParse(rawFrame);
|
||||
|
||||
return result.success ? [result.data] : [];
|
||||
});
|
||||
}
|
||||
|
||||
private parseJsonSafely(value: string) {
|
||||
try {
|
||||
return JSON.parse(value) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private createSockJsWebSocketUrl(baseUrl: string) {
|
||||
const url = new URL(baseUrl);
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||
url.pathname = `/${Date.now()}/${randomUUID()}/websocket`;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
private mapProject(project?: WarriorProject): ArchiveTeamWarriorStatus["project"] {
|
||||
if (!project) return null;
|
||||
|
||||
return {
|
||||
id: project.project_id?.toString(),
|
||||
title: project.title ?? "Unknown Project",
|
||||
};
|
||||
}
|
||||
|
||||
private mapItem(item: WarriorItem): ArchiveTeamWarriorItem {
|
||||
return {
|
||||
id: item.id,
|
||||
name: this.formatItemName(item.name),
|
||||
status: this.normalizeItemStatus(item.status),
|
||||
project: item.project,
|
||||
startTime: item.start_time,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeDisplayStatus(status?: string | null) {
|
||||
if (!status) return "Unknown";
|
||||
|
||||
switch (status) {
|
||||
case "RUNNING_PROJECT":
|
||||
return "Running";
|
||||
case "STOPPED":
|
||||
return "Stopped";
|
||||
case "IDLE":
|
||||
return "Idle";
|
||||
default:
|
||||
return status
|
||||
.replaceAll("_", " ")
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (character) => character.toUpperCase());
|
||||
}
|
||||
}
|
||||
|
||||
private formatItemName(name: string) {
|
||||
const cleaned = name.replace(/^Item\s+/i, "");
|
||||
const parts = cleaned.split("\u0000").filter(Boolean);
|
||||
|
||||
if (parts.length === 0) return "Item";
|
||||
|
||||
const first = parts[0];
|
||||
|
||||
if (!first) return "Item";
|
||||
|
||||
const label = parts.length === 1 ? first : `${first} + ${parts.length - 1} more`;
|
||||
|
||||
return label.length <= 80 ? label : `${label.slice(0, 77)}...`;
|
||||
}
|
||||
|
||||
private normalizeItemStatus(status?: string | null): ArchiveTeamWarriorItem["status"] {
|
||||
if (!status) return "running";
|
||||
|
||||
switch (status.toLowerCase()) {
|
||||
case "running":
|
||||
case "running_project":
|
||||
return "running";
|
||||
case "completed":
|
||||
return "completed";
|
||||
case "failed":
|
||||
case "fail":
|
||||
return "failed";
|
||||
case "canceled":
|
||||
case "cancel":
|
||||
return "canceled";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
private updateItemStatus(
|
||||
items: Map<string, ArchiveTeamWarriorItem>,
|
||||
itemId: string,
|
||||
status: ArchiveTeamWarriorItem["status"],
|
||||
) {
|
||||
const item = items.get(itemId);
|
||||
if (!item) return;
|
||||
|
||||
items.set(itemId, {
|
||||
...item,
|
||||
status,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const seesawFrameSchema = z.object({
|
||||
event_name: z.string(),
|
||||
message: z.unknown(),
|
||||
});
|
||||
|
||||
export const warriorStatusMessageSchema = z.object({
|
||||
status: z.string(),
|
||||
});
|
||||
|
||||
export const warriorBroadcastMessageSchema = z.object({
|
||||
message: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const warriorProjectSelectedMessageSchema = z.object({
|
||||
project: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const warriorProjectSchema = z.object({
|
||||
project_id: z.number().optional(),
|
||||
title: z.string().optional(),
|
||||
project_html: z.string().optional(),
|
||||
utc_deadline: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const warriorItemSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
status: z.string().nullable().optional(),
|
||||
project: z.string().nullable().optional(),
|
||||
start_time: z.number().optional(),
|
||||
});
|
||||
|
||||
export const projectRefreshMessageSchema = z
|
||||
.object({
|
||||
project: warriorProjectSchema.optional(),
|
||||
status: z.string().optional(),
|
||||
items: z.array(warriorItemSchema).optional(),
|
||||
})
|
||||
.nullable();
|
||||
|
||||
export const itemStatusMessageSchema = z.object({
|
||||
item_id: z.string(),
|
||||
});
|
||||
|
||||
export const pipelineStartItemMessageSchema = z.object({
|
||||
item: warriorItemSchema,
|
||||
});
|
||||
|
||||
export const bandwidthMessageSchema = z.object({
|
||||
received: z.number().optional(),
|
||||
sent: z.number().optional(),
|
||||
receiving: z.number().optional(),
|
||||
sending: z.number().optional(),
|
||||
session_id: z.string().optional(),
|
||||
});
|
||||
|
||||
export type WarriorProject = z.infer<typeof warriorProjectSchema>;
|
||||
export type WarriorItem = z.infer<typeof warriorItemSchema>;
|
||||
export type SeesawFrame = z.infer<typeof seesawFrameSchema>;
|
||||
@@ -1,37 +0,0 @@
|
||||
export interface ArchiveTeamWarriorItem {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "running" | "completed" | "failed" | "canceled" | "unknown";
|
||||
project?: string | null;
|
||||
startTime?: number;
|
||||
}
|
||||
|
||||
export interface ArchiveTeamWarriorBandwidth {
|
||||
received?: number;
|
||||
sent?: number;
|
||||
receiving?: number;
|
||||
sending?: number;
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
export interface ArchiveTeamWarriorProject {
|
||||
id?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface ArchiveTeamWarriorStatus {
|
||||
status: string;
|
||||
runnerStatus?: string;
|
||||
project?: ArchiveTeamWarriorProject | null;
|
||||
selectedProject?: string | null;
|
||||
broadcastMessage?: string | null;
|
||||
bandwidth?: ArchiveTeamWarriorBandwidth | null;
|
||||
items: ArchiveTeamWarriorItem[];
|
||||
counts: {
|
||||
running: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
canceled: number;
|
||||
};
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -3,12 +3,9 @@ import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
|
||||
import { BeszelIntegration } from "../beszel/beszel-integration";
|
||||
import { AnchorIntegration } from "../anchor/anchor-integration";
|
||||
import { ArchiveTeamWarriorIntegration } from "../archive-team-warrior/archive-team-warrior-integration";
|
||||
import { AudiobookshelfIntegration } from "../audiobookshelf/audiobookshelf-integration";
|
||||
import { CodebergIntegration } from "../codeberg/codeberg-integration";
|
||||
import { CoolifyIntegration } from "../coolify/coolify-integration";
|
||||
import { DashDotIntegration } from "../dashdot/dashdot-integration";
|
||||
import { DockerHubIntegration } from "../docker-hub/docker-hub-integration";
|
||||
import { Aria2Integration } from "../download-client/aria2/aria2-integration";
|
||||
import { DelugeIntegration } from "../download-client/deluge/deluge-integration";
|
||||
import { NzbGetIntegration } from "../download-client/nzbget/nzbget-integration";
|
||||
@@ -17,9 +14,6 @@ import { SabnzbdIntegration } from "../download-client/sabnzbd/sabnzbd-integrati
|
||||
import { SlskdIntegration } from "../download-client/slskd/slskd-integration";
|
||||
import { TransmissionIntegration } from "../download-client/transmission/transmission-integration";
|
||||
import { EmbyIntegration } from "../emby/emby-integration";
|
||||
import { GitHubContainerRegistryIntegration } from "../github-container-registry/github-container-registry-integration";
|
||||
import { GithubIntegration } from "../github/github-integration";
|
||||
import { GitlabIntegration } from "../gitlab/gitlab-integration";
|
||||
import { GlancesIntegration } from "../glances/glances-integration";
|
||||
import { GluetunIntegration } from "../gluetun/gluetun-integration";
|
||||
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
|
||||
@@ -27,7 +21,6 @@ import { ICalIntegration } from "../ical/ical-integration";
|
||||
import { ImmichIntegration } from "../immich/immich-integration";
|
||||
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
|
||||
import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration";
|
||||
import { LinuxServerIOIntegration } from "../linuxserverio/linuxserverio-integration";
|
||||
import { LidarrIntegration } from "../media-organizer/lidarr/lidarr-integration";
|
||||
import { RadarrIntegration } from "../media-organizer/radarr/radarr-integration";
|
||||
import { ReadarrIntegration } from "../media-organizer/readarr/readarr-integration";
|
||||
@@ -36,7 +29,6 @@ import { TdarrIntegration } from "../media-transcoding/tdarr-integration";
|
||||
import { MockIntegration } from "../mock/mock-integration";
|
||||
import { NavidromeIntegration } from "../navidrome/navidrome-integration";
|
||||
import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
|
||||
import { NPMIntegration } from "../npm/npm-integration";
|
||||
import { GotifyIntegration } from "../gotify/gotify-integration";
|
||||
import { NTFYIntegration } from "../ntfy/ntfy-integration";
|
||||
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
|
||||
@@ -49,8 +41,6 @@ import { createTechnitiumDnsIntegrationAsync } from "../technitium/technitium-in
|
||||
import { PlexIntegration } from "../plex/plex-integration";
|
||||
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
|
||||
import { ProxmoxIntegration } from "../proxmox/proxmox-integration";
|
||||
import { QuayIntegration } from "../quay/quay-integration";
|
||||
import { SearchChIntegration } from "../search-ch/search-ch-integration";
|
||||
import { SeerrIntegration } from "../seerr/seerr-integration";
|
||||
import { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-tracker-integration";
|
||||
import { TracearrIntegration } from "../tracearr/tracearr-integration";
|
||||
@@ -114,15 +104,7 @@ export const integrationCreators = {
|
||||
nextcloud: NextcloudIntegration,
|
||||
unifiController: UnifiControllerIntegration,
|
||||
opnsense: OPNsenseIntegration,
|
||||
github: GithubIntegration,
|
||||
dockerHub: DockerHubIntegration,
|
||||
gitlab: GitlabIntegration,
|
||||
npm: NPMIntegration,
|
||||
codeberg: CodebergIntegration,
|
||||
linuxServerIO: LinuxServerIOIntegration,
|
||||
gitHubContainerRegistry: GitHubContainerRegistryIntegration,
|
||||
ical: ICalIntegration,
|
||||
quay: QuayIntegration,
|
||||
ntfy: NTFYIntegration,
|
||||
gotify: GotifyIntegration,
|
||||
mock: MockIntegration,
|
||||
@@ -131,7 +113,6 @@ export const integrationCreators = {
|
||||
coolify: CoolifyIntegration,
|
||||
tracearr: TracearrIntegration,
|
||||
glances: GlancesIntegration,
|
||||
searchCh: SearchChIntegration,
|
||||
immich: ImmichIntegration,
|
||||
paperlessNgx: PaperlessNgxIntegration,
|
||||
speedtestTracker: SpeedtestTrackerIntegration,
|
||||
@@ -139,7 +120,6 @@ export const integrationCreators = {
|
||||
navidrome: NavidromeIntegration,
|
||||
umami: UmamiIntegration,
|
||||
gluetun: GluetunIntegration,
|
||||
archiveTeamWarrior: ArchiveTeamWarriorIntegration,
|
||||
uptimeKuma: UptimeKumaIntegration,
|
||||
peaNut: PeaNutIntegration,
|
||||
beszel: BeszelIntegration,
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import type { RequestInit, Response } from "undici";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
|
||||
import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
|
||||
import type {
|
||||
DetailsProviderResponse,
|
||||
ReleaseResponse,
|
||||
} from "../interfaces/releases-providers/releases-providers-types";
|
||||
import { detailsResponseSchema, releasesResponseSchema } from "./codeberg-schemas";
|
||||
|
||||
const logger = createLogger({ module: "codebergIntegration" });
|
||||
|
||||
export class CodebergIntegration extends Integration implements ReleasesProviderIntegration {
|
||||
private async withHeadersAsync(callback: (headers: RequestInit["headers"]) => Promise<Response>): Promise<Response> {
|
||||
if (!this.hasSecretValue("personalAccessToken")) return await callback(undefined);
|
||||
|
||||
return await callback({
|
||||
Authorization: `token ${this.getSecretValue("personalAccessToken")}`,
|
||||
});
|
||||
}
|
||||
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await this.withHeadersAsync(async (headers) => {
|
||||
return await input.fetchAsync(this.url("/version"), {
|
||||
headers,
|
||||
});
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return TestConnectionError.StatusResult(response);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
private parseIdentifier(identifier: string) {
|
||||
const [owner, name] = identifier.split("/");
|
||||
if (!owner || !name) {
|
||||
logger.warn("Invalid identifier format. Expected 'owner/name', for identifier", {
|
||||
identifier,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return { owner, name };
|
||||
}
|
||||
|
||||
public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise<ReleaseResponse> {
|
||||
const parsedIdentifier = this.parseIdentifier(identifier);
|
||||
if (!parsedIdentifier) return { success: false, error: { code: "invalidIdentifier" } };
|
||||
|
||||
const { owner, name } = parsedIdentifier;
|
||||
|
||||
const releasesResponse = await this.withHeadersAsync(async (headers) => {
|
||||
return await fetchWithTrustedCertificatesAsync(
|
||||
this.url(`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/releases`),
|
||||
{ headers },
|
||||
);
|
||||
});
|
||||
if (!releasesResponse.ok) {
|
||||
return { success: false, error: { code: "unexpected", message: releasesResponse.statusText } };
|
||||
}
|
||||
|
||||
const releasesResponseJson: unknown = await releasesResponse.json();
|
||||
const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson);
|
||||
if (!success) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: "unexpected",
|
||||
message: releasesResponseJson ? JSON.stringify(releasesResponseJson, null, 2) : error.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const formattedReleases = data.map((tag) => ({
|
||||
latestRelease: tag.tag_name,
|
||||
latestReleaseAt: tag.published_at,
|
||||
releaseUrl: tag.url,
|
||||
releaseDescription: tag.body,
|
||||
isPreRelease: tag.prerelease,
|
||||
}));
|
||||
|
||||
const latestRelease = getLatestRelease(formattedReleases, versionRegex);
|
||||
if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } };
|
||||
|
||||
const details = await this.getDetailsAsync(owner, name);
|
||||
|
||||
return { success: true, data: { ...details, ...latestRelease } };
|
||||
}
|
||||
|
||||
protected async getDetailsAsync(owner: string, name: string): Promise<DetailsProviderResponse | undefined> {
|
||||
const response = await this.withHeadersAsync(async (headers) => {
|
||||
return await fetchWithTrustedCertificatesAsync(
|
||||
this.url(`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`),
|
||||
{
|
||||
headers,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn("Failed to get details", {
|
||||
owner,
|
||||
name,
|
||||
error: response.statusText,
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const responseJson = await response.json();
|
||||
const { data, success, error } = detailsResponseSchema.safeParse(responseJson);
|
||||
|
||||
if (!success) {
|
||||
logger.warn("Failed to parse details", {
|
||||
owner,
|
||||
name,
|
||||
error,
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
projectUrl: data.html_url,
|
||||
projectDescription: data.description,
|
||||
isFork: data.fork,
|
||||
isArchived: data.archived,
|
||||
createdAt: data.created_at,
|
||||
starsCount: data.stars_count,
|
||||
openIssues: data.open_issues_count,
|
||||
forksCount: data.forks_count,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const releasesResponseSchema = z.array(
|
||||
z.object({
|
||||
tag_name: z.string(),
|
||||
published_at: z.string().transform((value) => new Date(value)),
|
||||
url: z.string(),
|
||||
body: z.string(),
|
||||
prerelease: z.boolean(),
|
||||
}),
|
||||
);
|
||||
|
||||
export const detailsResponseSchema = z.object({
|
||||
html_url: z.string(),
|
||||
description: z.string(),
|
||||
fork: z.boolean(),
|
||||
archived: z.boolean(),
|
||||
created_at: z.string().transform((value) => new Date(value)),
|
||||
stars_count: z.number(),
|
||||
open_issues_count: z.number(),
|
||||
forks_count: z.number(),
|
||||
});
|
||||
@@ -1,190 +0,0 @@
|
||||
import type { fetch, RequestInit, Response } from "undici";
|
||||
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import type { SessionStore } from "../base/session-store";
|
||||
import { createSessionStore } from "../base/session-store";
|
||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
|
||||
import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
|
||||
import type {
|
||||
DetailsProviderResponse,
|
||||
ReleaseResponse,
|
||||
} from "../interfaces/releases-providers/releases-providers-types";
|
||||
import { accessTokenResponseSchema, detailsResponseSchema, releasesResponseSchema } from "./docker-hub-schemas";
|
||||
|
||||
const logger = createLogger({ module: "dockerHubIntegration" });
|
||||
|
||||
export class DockerHubIntegration extends Integration implements ReleasesProviderIntegration {
|
||||
private readonly sessionStore: SessionStore<string>;
|
||||
|
||||
constructor(integration: IntegrationInput) {
|
||||
super(integration);
|
||||
this.sessionStore = createSessionStore(integration);
|
||||
}
|
||||
|
||||
private async withHeadersAsync(callback: (headers: RequestInit["headers"]) => Promise<Response>): Promise<Response> {
|
||||
if (!this.hasSecretValue("username") || !this.hasSecretValue("personalAccessToken"))
|
||||
return await callback(undefined);
|
||||
|
||||
const storedSession = await this.sessionStore.getAsync();
|
||||
|
||||
if (storedSession) {
|
||||
logger.debug("Using stored session for request", { integrationId: this.integration.id });
|
||||
const response = await callback({
|
||||
Authorization: `Bearer ${storedSession}`,
|
||||
});
|
||||
if (response.status !== 401) {
|
||||
return response;
|
||||
}
|
||||
|
||||
logger.debug("Session expired, getting new session", { integrationId: this.integration.id });
|
||||
}
|
||||
|
||||
const accessToken = await this.getSessionAsync();
|
||||
await this.sessionStore.setAsync(accessToken);
|
||||
return await callback({
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
});
|
||||
}
|
||||
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const hasAuth = this.hasSecretValue("username") && this.hasSecretValue("personalAccessToken");
|
||||
|
||||
if (hasAuth) {
|
||||
logger.debug("Testing DockerHub connection with authentication", { integrationId: this.integration.id });
|
||||
await this.getSessionAsync(input.fetchAsync);
|
||||
} else {
|
||||
logger.debug("Testing DockerHub connection without authentication", { integrationId: this.integration.id });
|
||||
const response = await input.fetchAsync(this.url("/v2/repositories/library"));
|
||||
if (!response.ok) {
|
||||
return TestConnectionError.StatusResult(response);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
private parseIdentifier(identifier: string) {
|
||||
if (!identifier.includes("/")) return { owner: "", name: identifier };
|
||||
const [owner, name] = identifier.split("/");
|
||||
if (!owner || !name) {
|
||||
logger.warn("Invalid identifier format. Expected 'owner/name' or 'name', for identifier", {
|
||||
identifier,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return { owner, name };
|
||||
}
|
||||
|
||||
public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise<ReleaseResponse> {
|
||||
const parsedIdentifier = this.parseIdentifier(identifier);
|
||||
if (!parsedIdentifier) return { success: false, error: { code: "invalidIdentifier" } };
|
||||
|
||||
const { owner, name } = parsedIdentifier;
|
||||
|
||||
const relativeUrl: `/${string}` = owner
|
||||
? `/v2/namespaces/${encodeURIComponent(owner)}/repositories/${encodeURIComponent(name)}`
|
||||
: `/v2/repositories/library/${encodeURIComponent(name)}`;
|
||||
|
||||
for (let page = 0; page <= 5; page++) {
|
||||
const releasesResponse = await this.withHeadersAsync(async (headers) => {
|
||||
return await fetchWithTrustedCertificatesAsync(this.url(`${relativeUrl}/tags?page_size=100&page=${page}`), {
|
||||
headers,
|
||||
});
|
||||
});
|
||||
if (!releasesResponse.ok) {
|
||||
return { success: false, error: { code: "unexpected", message: releasesResponse.statusText } };
|
||||
}
|
||||
|
||||
const releasesResponseJson: unknown = await releasesResponse.json();
|
||||
const releasesResult = releasesResponseSchema.safeParse(releasesResponseJson);
|
||||
if (!releasesResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: "unexpected",
|
||||
message: releasesResponseJson
|
||||
? JSON.stringify(releasesResponseJson, null, 2)
|
||||
: releasesResult.error.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const latestRelease = getLatestRelease(releasesResult.data.results, versionRegex);
|
||||
if (!latestRelease) continue;
|
||||
|
||||
const details = await this.getDetailsAsync(relativeUrl);
|
||||
|
||||
return { success: true, data: { ...details, ...latestRelease } };
|
||||
}
|
||||
|
||||
return { success: false, error: { code: "noMatchingVersion" } };
|
||||
}
|
||||
|
||||
private async getDetailsAsync(relativeUrl: `/${string}`): Promise<DetailsProviderResponse | undefined> {
|
||||
const response = await this.withHeadersAsync(async (headers) => {
|
||||
return await fetchWithTrustedCertificatesAsync(this.url(`${relativeUrl}/`), {
|
||||
headers,
|
||||
});
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn("Failed to get details response", {
|
||||
relativeUrl,
|
||||
error: response.statusText,
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const responseJson = await response.json();
|
||||
const { data, success, error } = detailsResponseSchema.safeParse(responseJson);
|
||||
|
||||
if (!success) {
|
||||
logger.warn("Failed to parse details response", {
|
||||
relativeUrl,
|
||||
error,
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
projectUrl: `https://hub.docker.com/r/${data.namespace === "library" ? "_" : data.namespace}/${data.name}`,
|
||||
projectDescription: data.description,
|
||||
createdAt: data.date_registered,
|
||||
starsCount: data.star_count,
|
||||
};
|
||||
}
|
||||
|
||||
private async getSessionAsync(fetchAsync: typeof fetch = fetchWithTrustedCertificatesAsync): Promise<string> {
|
||||
const response = await fetchAsync(this.url("/v2/auth/token"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
identifier: this.getSecretValue("username"),
|
||||
secret: this.getSecretValue("personalAccessToken"),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new ResponseError(response);
|
||||
|
||||
const data = await response.json();
|
||||
const result = await accessTokenResponseSchema.parseAsync(data);
|
||||
|
||||
if (!result.access_token) {
|
||||
throw new ResponseError({ status: 401, url: response.url });
|
||||
}
|
||||
|
||||
logger.info("Received session successfully", { integrationId: this.integration.id });
|
||||
|
||||
return result.access_token;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const accessTokenResponseSchema = z.object({
|
||||
access_token: z.string(),
|
||||
});
|
||||
|
||||
export const releasesResponseSchema = z.object({
|
||||
results: z.array(
|
||||
z.object({ name: z.string(), last_updated: z.string().transform((value) => new Date(value)) }).transform((tag) => ({
|
||||
latestRelease: tag.name,
|
||||
latestReleaseAt: tag.last_updated,
|
||||
})),
|
||||
),
|
||||
});
|
||||
|
||||
export const detailsResponseSchema = z.object({
|
||||
name: z.string(),
|
||||
namespace: z.string(),
|
||||
description: z.string(),
|
||||
star_count: z.number(),
|
||||
date_registered: z.string().transform((value) => new Date(value)),
|
||||
});
|
||||
-166
@@ -1,166 +0,0 @@
|
||||
import { createAppAuth } from "@octokit/auth-app";
|
||||
import { Octokit, RequestError } from "octokit";
|
||||
import type { fetch } from "undici";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import { HandleIntegrationErrors } from "../base/errors/decorator";
|
||||
import { integrationOctokitHttpErrorHandler } from "../base/errors/http";
|
||||
import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
|
||||
import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
|
||||
import type {
|
||||
DetailsProviderResponse,
|
||||
ReleaseProviderResponse,
|
||||
ReleaseResponse,
|
||||
} from "../interfaces/releases-providers/releases-providers-types";
|
||||
|
||||
const logger = createLogger({ module: "githubContainerRegistryIntegration" });
|
||||
|
||||
@HandleIntegrationErrors([integrationOctokitHttpErrorHandler])
|
||||
export class GitHubContainerRegistryIntegration extends Integration implements ReleasesProviderIntegration {
|
||||
private static readonly userAgent = "Homarr-Lab/Homarr:GitHubContainerRegistryIntegration";
|
||||
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const api = this.getApi(input.fetchAsync);
|
||||
|
||||
if (this.hasSecretValue("personalAccessToken")) {
|
||||
await api.rest.users.getAuthenticated();
|
||||
} else if (this.hasSecretValue("githubAppId")) {
|
||||
await api.rest.apps.getInstallation({
|
||||
installation_id: Number(this.getSecretValue("githubInstallationId")),
|
||||
});
|
||||
} else {
|
||||
await api.request("GET /octocat");
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
private parseIdentifier(identifier: string) {
|
||||
const [owner, name] = identifier.split("/");
|
||||
if (!owner || !name) {
|
||||
logger.warn("Invalid identifier format. Expected 'owner/name', for identifier", { identifier });
|
||||
return null;
|
||||
}
|
||||
return { owner, name };
|
||||
}
|
||||
|
||||
public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise<ReleaseResponse> {
|
||||
const parsedIdentifier = this.parseIdentifier(identifier);
|
||||
if (!parsedIdentifier) return { success: false, error: { code: "invalidIdentifier" } };
|
||||
|
||||
const { owner, name } = parsedIdentifier;
|
||||
const api = this.getApi();
|
||||
|
||||
try {
|
||||
const releasesResponse = await api.rest.packages.getAllPackageVersionsForPackageOwnedByUser({
|
||||
username: owner,
|
||||
package_type: "container",
|
||||
package_name: name,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const releasesProviderResponse = releasesResponse.data.reduce<ReleaseProviderResponse[]>((acc, release) => {
|
||||
if (!release.metadata?.container?.tags || !(release.metadata.container.tags.length > 0)) return acc;
|
||||
|
||||
release.metadata.container.tags.forEach((tag) => {
|
||||
acc.push({
|
||||
latestRelease: tag,
|
||||
latestReleaseAt: new Date(release.updated_at),
|
||||
releaseUrl: release.html_url,
|
||||
releaseDescription: release.description ?? undefined,
|
||||
});
|
||||
});
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const latestRelease = getLatestRelease(releasesProviderResponse, versionRegex);
|
||||
if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } };
|
||||
|
||||
const details = await this.getDetailsAsync(api, owner, name);
|
||||
|
||||
return { success: true, data: { ...details, ...latestRelease } };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof RequestError ? error.message : String(error);
|
||||
logger.warn("Failed to get releases", {
|
||||
owner,
|
||||
name,
|
||||
error: errorMessage,
|
||||
});
|
||||
return { success: false, error: { code: "unexpected", message: errorMessage } };
|
||||
}
|
||||
}
|
||||
|
||||
protected async getDetailsAsync(
|
||||
api: Octokit,
|
||||
owner: string,
|
||||
name: string,
|
||||
): Promise<DetailsProviderResponse | undefined> {
|
||||
try {
|
||||
const response = await api.rest.packages.getPackageForUser({
|
||||
username: owner,
|
||||
package_type: "container",
|
||||
package_name: name,
|
||||
});
|
||||
|
||||
return {
|
||||
projectUrl: response.data.repository?.html_url ?? response.data.html_url,
|
||||
projectDescription: response.data.repository?.description ?? undefined,
|
||||
isFork: response.data.repository?.fork,
|
||||
isArchived: response.data.repository?.archived,
|
||||
createdAt: new Date(response.data.created_at),
|
||||
starsCount: response.data.repository?.stargazers_count,
|
||||
openIssues: response.data.repository?.open_issues_count,
|
||||
forksCount: response.data.repository?.forks_count,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn("Failed to get details", {
|
||||
owner,
|
||||
name,
|
||||
error: error instanceof RequestError ? error.message : String(error),
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private getAuthProperties(): Pick<OctokitOptions, "auth" | "authStrategy"> {
|
||||
if (this.hasSecretValue("personalAccessToken"))
|
||||
return {
|
||||
auth: this.getSecretValue("personalAccessToken"),
|
||||
};
|
||||
|
||||
if (this.hasSecretValue("githubAppId"))
|
||||
return {
|
||||
authStrategy: createAppAuth,
|
||||
auth: {
|
||||
appId: this.getSecretValue("githubAppId"),
|
||||
installationId: this.getSecretValue("githubInstallationId"),
|
||||
privateKey: this.getSecretValue("privateKey"),
|
||||
} satisfies Parameters<typeof createAppAuth>[0],
|
||||
};
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
private getApi(customFetch?: typeof fetch) {
|
||||
return new Octokit({
|
||||
baseUrl: this.url("/").origin,
|
||||
request: {
|
||||
fetch: customFetch ?? fetchWithTrustedCertificatesAsync,
|
||||
},
|
||||
userAgent: GitHubContainerRegistryIntegration.userAgent,
|
||||
// Disable throttling for this integration, Octokit will retry by default after a set time,
|
||||
// thus delaying the repsonse to the user in case of errors. Errors will be shown to the user, no need to retry the request.
|
||||
throttle: { enabled: false },
|
||||
...this.getAuthProperties(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type OctokitOptions = Exclude<ConstructorParameters<typeof Octokit>[0], undefined>;
|
||||
@@ -1,168 +0,0 @@
|
||||
import { createAppAuth } from "@octokit/auth-app";
|
||||
import { Octokit, RequestError as OctokitRequestError } from "octokit";
|
||||
import type { fetch } from "undici";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import { HandleIntegrationErrors } from "../base/errors/decorator";
|
||||
import { integrationOctokitHttpErrorHandler } from "../base/errors/http";
|
||||
import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
|
||||
import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
|
||||
import type {
|
||||
DetailsProviderResponse,
|
||||
ReleaseProviderResponse,
|
||||
ReleaseResponse,
|
||||
} from "../interfaces/releases-providers/releases-providers-types";
|
||||
|
||||
const logger = createLogger({ module: "githubIntegration" });
|
||||
|
||||
@HandleIntegrationErrors([integrationOctokitHttpErrorHandler])
|
||||
export class GithubIntegration extends Integration implements ReleasesProviderIntegration {
|
||||
private static readonly userAgent = "Homarr-Lab/Homarr:GithubIntegration";
|
||||
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const api = this.getApi(input.fetchAsync);
|
||||
|
||||
if (this.hasSecretValue("personalAccessToken")) {
|
||||
await api.rest.users.getAuthenticated();
|
||||
} else if (this.hasSecretValue("githubAppId")) {
|
||||
await api.rest.apps.getInstallation({
|
||||
installation_id: Number(this.getSecretValue("githubInstallationId")),
|
||||
});
|
||||
} else {
|
||||
await api.request("GET /octocat");
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
private parseIdentifier(identifier: string) {
|
||||
const [owner, name] = identifier.split("/");
|
||||
if (!owner || !name) {
|
||||
logger.warn("Invalid identifier format. Expected 'owner/name' for identifier", {
|
||||
identifier,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return { owner, name };
|
||||
}
|
||||
|
||||
public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise<ReleaseResponse> {
|
||||
const parsedIdentifier = this.parseIdentifier(identifier);
|
||||
if (!parsedIdentifier) return { success: false, error: { code: "invalidIdentifier" } };
|
||||
|
||||
const { owner, name } = parsedIdentifier;
|
||||
const api = this.getApi();
|
||||
|
||||
try {
|
||||
const releasesResponse = await api.rest.repos.listReleases({ owner, repo: name });
|
||||
|
||||
if (releasesResponse.data.length === 0) {
|
||||
logger.warn("No releases found", {
|
||||
identifier: `${owner}/${name}`,
|
||||
});
|
||||
return { success: false, error: { code: "noMatchingVersion" } };
|
||||
}
|
||||
|
||||
const releasesProviderResponse = releasesResponse.data.reduce<ReleaseProviderResponse[]>((acc, release) => {
|
||||
if (!release.published_at) return acc;
|
||||
|
||||
acc.push({
|
||||
latestRelease: release.tag_name,
|
||||
latestReleaseAt: new Date(release.published_at),
|
||||
releaseUrl: release.html_url,
|
||||
releaseDescription: release.body ?? undefined,
|
||||
isPreRelease: release.prerelease,
|
||||
});
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const latestRelease = getLatestRelease(releasesProviderResponse, versionRegex);
|
||||
if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } };
|
||||
|
||||
const details = await this.getDetailsAsync(api, owner, name);
|
||||
|
||||
return { success: true, data: { ...details, ...latestRelease } };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof OctokitRequestError ? error.message : String(error);
|
||||
logger.warn("Failed to get releases", {
|
||||
owner,
|
||||
name,
|
||||
error: errorMessage,
|
||||
});
|
||||
return { success: false, error: { code: "unexpected", message: errorMessage } };
|
||||
}
|
||||
}
|
||||
|
||||
protected async getDetailsAsync(
|
||||
api: Octokit,
|
||||
owner: string,
|
||||
name: string,
|
||||
): Promise<DetailsProviderResponse | undefined> {
|
||||
try {
|
||||
const response = await api.rest.repos.get({
|
||||
owner,
|
||||
repo: name,
|
||||
});
|
||||
|
||||
return {
|
||||
projectUrl: response.data.html_url,
|
||||
projectDescription: response.data.description ?? undefined,
|
||||
isFork: response.data.fork,
|
||||
isArchived: response.data.archived,
|
||||
createdAt: new Date(response.data.created_at),
|
||||
starsCount: response.data.stargazers_count,
|
||||
openIssues: response.data.open_issues_count,
|
||||
forksCount: response.data.forks_count,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn("Failed to get details", {
|
||||
owner,
|
||||
name,
|
||||
error: error instanceof OctokitRequestError ? error.message : String(error),
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private getAuthProperties(): Pick<OctokitOptions, "auth" | "authStrategy"> {
|
||||
if (this.hasSecretValue("personalAccessToken"))
|
||||
return {
|
||||
auth: this.getSecretValue("personalAccessToken"),
|
||||
};
|
||||
|
||||
if (this.hasSecretValue("githubAppId"))
|
||||
return {
|
||||
authStrategy: createAppAuth,
|
||||
auth: {
|
||||
appId: this.getSecretValue("githubAppId"),
|
||||
installationId: this.getSecretValue("githubInstallationId"),
|
||||
privateKey: this.getSecretValue("privateKey"),
|
||||
} satisfies Parameters<typeof createAppAuth>[0],
|
||||
};
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
private getApi(customFetch?: typeof fetch) {
|
||||
return new Octokit({
|
||||
baseUrl: this.url("/").origin,
|
||||
request: {
|
||||
fetch: customFetch ?? fetchWithTrustedCertificatesAsync,
|
||||
},
|
||||
userAgent: GithubIntegration.userAgent,
|
||||
// Disable throttling for this integration, Octokit will retry by default after a set time,
|
||||
// thus delaying the repsonse to the user in case of errors. Errors will be shown to the user, no need to retry the request.
|
||||
throttle: { enabled: false },
|
||||
...this.getAuthProperties(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type OctokitOptions = Exclude<ConstructorParameters<typeof Octokit>[0], undefined>;
|
||||
@@ -1,155 +0,0 @@
|
||||
import type { Gitlab as CoreGitlab } from "@gitbeaker/core";
|
||||
import { createRequesterFn, defaultOptionsHandler } from "@gitbeaker/requester-utils";
|
||||
import type { FormattedResponse, RequestOptions, ResourceOptions } from "@gitbeaker/requester-utils";
|
||||
import { Gitlab } from "@gitbeaker/rest";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
|
||||
import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
|
||||
import type {
|
||||
DetailsProviderResponse,
|
||||
ReleaseProviderResponse,
|
||||
ReleaseResponse,
|
||||
} from "../interfaces/releases-providers/releases-providers-types";
|
||||
|
||||
const logger = createLogger({ module: "gitlabIntegration" });
|
||||
|
||||
export class GitlabIntegration extends Integration implements ReleasesProviderIntegration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(this.url("/api/v4/projects"), {
|
||||
headers: {
|
||||
...(this.hasSecretValue("personalAccessToken")
|
||||
? { Authorization: `Bearer ${this.getSecretValue("personalAccessToken")}` }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return TestConnectionError.StatusResult(response);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise<ReleaseResponse> {
|
||||
const api = this.getApi();
|
||||
|
||||
try {
|
||||
const releasesResponse = await api.ProjectReleases.all(identifier, {
|
||||
perPage: 100,
|
||||
});
|
||||
|
||||
if (releasesResponse instanceof Error) {
|
||||
logger.warn("No releases found", {
|
||||
identifier,
|
||||
error: releasesResponse.message,
|
||||
});
|
||||
return { success: false, error: { code: "noReleasesFound" } };
|
||||
}
|
||||
|
||||
const releasesProviderResponse = releasesResponse.reduce<ReleaseProviderResponse[]>((acc, release) => {
|
||||
if (!release.released_at) return acc;
|
||||
|
||||
const releaseDate = new Date(release.released_at);
|
||||
|
||||
acc.push({
|
||||
latestRelease: release.name ?? release.tag_name,
|
||||
latestReleaseAt: releaseDate,
|
||||
releaseUrl: release._links.self,
|
||||
releaseDescription: release.description ?? undefined,
|
||||
isPreRelease: releaseDate > new Date(), // For upcoming releases the `released_at` will be set to the future (https://docs.gitlab.com/api/releases/#upcoming-releases). Gitbreaker doesn't currently support the `upcoming_release` field (https://github.com/jdalrymple/gitbeaker/issues/3730)
|
||||
});
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const latestRelease = getLatestRelease(releasesProviderResponse, versionRegex);
|
||||
if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } };
|
||||
|
||||
const details = await this.getDetailsAsync(api, identifier);
|
||||
|
||||
return { success: true, data: { ...details, ...latestRelease } };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.warn("Failed to get releases", {
|
||||
identifier,
|
||||
error: errorMessage,
|
||||
});
|
||||
return { success: false, error: { code: "unexpected", message: errorMessage } };
|
||||
}
|
||||
}
|
||||
|
||||
protected async getDetailsAsync(api: CoreGitlab, identifier: string): Promise<DetailsProviderResponse | undefined> {
|
||||
try {
|
||||
const response = await api.Projects.show(identifier);
|
||||
|
||||
if (response instanceof Error) {
|
||||
logger.warn("Failed to get details", {
|
||||
identifier,
|
||||
error: response.message,
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!response.web_url) {
|
||||
logger.warn("No web URL found", {
|
||||
identifier,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
projectUrl: response.web_url,
|
||||
projectDescription: response.description ?? undefined,
|
||||
isFork: response.forked_from_project !== null,
|
||||
isArchived: response.archived,
|
||||
createdAt: new Date(response.created_at),
|
||||
starsCount: response.star_count,
|
||||
openIssues: response.open_issues_count,
|
||||
forksCount: response.forks_count,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn("Failed to get details", {
|
||||
identifier,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private getApi() {
|
||||
return new Gitlab({
|
||||
host: this.url("/").origin,
|
||||
requesterFn: createRequesterFn(
|
||||
async (serviceOptions: ResourceOptions, _: RequestOptions) => await defaultOptionsHandler(serviceOptions),
|
||||
async (endpoint: string, options?: Record<string, unknown>): Promise<FormattedResponse> => {
|
||||
if (options === undefined) {
|
||||
throw new Error("Gitlab library is not configured correctly. Options must be provided.");
|
||||
}
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(
|
||||
`${options.prefixUrl as string}${endpoint}`,
|
||||
options,
|
||||
);
|
||||
const headers = Object.fromEntries(response.headers.entries());
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
headers,
|
||||
body: await response.json(),
|
||||
} as FormattedResponse;
|
||||
},
|
||||
),
|
||||
...(this.hasSecretValue("personalAccessToken") ? { token: this.getSecretValue("personalAccessToken") } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
// General integrations
|
||||
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
|
||||
export { ArchiveTeamWarriorIntegration } from "./archive-team-warrior/archive-team-warrior-integration";
|
||||
export { TechnitiumDnsIntegration } from "./technitium/technitium-integration";
|
||||
export { AnchorIntegration } from "./anchor/anchor-integration";
|
||||
export { Aria2Integration } from "./download-client/aria2/aria2-integration";
|
||||
@@ -64,7 +63,6 @@ export type {
|
||||
TdarrStatistics,
|
||||
TdarrWorker,
|
||||
} from "./interfaces/media-transcoding/media-transcoding-types";
|
||||
export type { ReleasesRepository, ReleaseResponse } from "./interfaces/releases-providers/releases-providers-types";
|
||||
export type { Notification } from "./interfaces/notifications/notification-types";
|
||||
export type { ImmichServerStats, ImmichAlbum, ImmichAsset } from "./immich/immich-integration";
|
||||
export type { PaperlessNgxStats } from "./paperless-ngx/paperless-ngx-types";
|
||||
@@ -75,11 +73,6 @@ export type {
|
||||
AnchorNotesListInput,
|
||||
AnchorNoteSummary,
|
||||
} from "./anchor/anchor-types";
|
||||
export type {
|
||||
ArchiveTeamWarriorBandwidth,
|
||||
ArchiveTeamWarriorItem,
|
||||
ArchiveTeamWarriorStatus,
|
||||
} from "./archive-team-warrior/archive-team-warrior-types";
|
||||
export type { TracearrDashboardData } from "./tracearr/tracearr-types";
|
||||
export type { SpeedtestTrackerDashboardData } from "./speedtest-tracker/speedtest-tracker-types";
|
||||
export type { AudiobookshelfDashboardData } from "./audiobookshelf/audiobookshelf-types";
|
||||
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
import type { ReleaseProviderResponse, ReleaseResponse } from "./releases-providers-types";
|
||||
|
||||
export const getLatestRelease = (
|
||||
releases: ReleaseProviderResponse[],
|
||||
versionRegex?: string,
|
||||
): ReleaseProviderResponse | null => {
|
||||
const validReleases = releases.filter((result) => {
|
||||
if (result.latestRelease) {
|
||||
return versionRegex ? new RegExp(versionRegex).test(result.latestRelease) : true;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return validReleases.length === 0
|
||||
? null
|
||||
: validReleases.reduce((latest, current) => (current.latestReleaseAt > latest.latestReleaseAt ? current : latest));
|
||||
};
|
||||
|
||||
export interface ReleasesProviderIntegration {
|
||||
getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise<ReleaseResponse>;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
|
||||
export interface ReleasesRepository extends Record<string, unknown> {
|
||||
id: string;
|
||||
identifier: string;
|
||||
versionRegex?: string;
|
||||
}
|
||||
|
||||
export interface DetailsProviderResponse {
|
||||
projectUrl?: string;
|
||||
projectDescription?: string;
|
||||
isFork?: boolean;
|
||||
isArchived?: boolean;
|
||||
createdAt?: Date;
|
||||
starsCount?: number;
|
||||
openIssues?: number;
|
||||
forksCount?: number;
|
||||
}
|
||||
|
||||
export interface ReleaseProviderResponse {
|
||||
latestRelease: string;
|
||||
latestReleaseAt: Date;
|
||||
releaseUrl?: string;
|
||||
releaseDescription?: string;
|
||||
isPreRelease?: boolean;
|
||||
}
|
||||
|
||||
type ReleasesErrorKeys = keyof TranslationObject["widget"]["releases"]["error"]["messages"];
|
||||
|
||||
export type ReleaseData = DetailsProviderResponse & ReleaseProviderResponse;
|
||||
|
||||
export type ReleaseError = { code: ReleasesErrorKeys } | { code: "unexpected"; message: string };
|
||||
|
||||
export type ReleaseResponse = { success: true; data: ReleaseData } | { success: false; error: ReleaseError };
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { Station, Timetable, TimetableOptions } from "./timetable-types";
|
||||
|
||||
export interface TimetableIntegration {
|
||||
searchStationsAsync(query: string): Promise<Station[]>;
|
||||
getTimetableAsync(options: TimetableOptions): Promise<Timetable>;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import z from "zod";
|
||||
|
||||
export interface Station {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const timetableOptionsSchema = z.object({
|
||||
stationId: z.string(),
|
||||
limit: z.number().default(10),
|
||||
});
|
||||
|
||||
export type TimetableOptions = z.infer<typeof timetableOptionsSchema>;
|
||||
|
||||
export interface TimetableEntry {
|
||||
timestamp: Date;
|
||||
delay: number;
|
||||
line: {
|
||||
name: string;
|
||||
color: string | null;
|
||||
} | null;
|
||||
location: string;
|
||||
platform: {
|
||||
name: string;
|
||||
hasChanged: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface Timetable {
|
||||
stationId: string;
|
||||
timestamp: Date;
|
||||
entries: TimetableEntry[];
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
|
||||
import type { ReleaseResponse } from "../interfaces/releases-providers/releases-providers-types";
|
||||
import { releasesResponseSchema } from "./linuxserverio-schemas";
|
||||
|
||||
const logger = createLogger({ module: "linuxServerIOIntegration" });
|
||||
|
||||
export class LinuxServerIOIntegration extends Integration implements ReleasesProviderIntegration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(this.url("/health"));
|
||||
|
||||
if (!response.ok) {
|
||||
return TestConnectionError.StatusResult(response);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
private parseIdentifier(identifier: string) {
|
||||
const [owner, name] = identifier.split("/");
|
||||
if (!owner || !name) {
|
||||
logger.warn("Invalid identifier format. Expected 'owner/name' for identifier", { identifier });
|
||||
return null;
|
||||
}
|
||||
return { owner, name };
|
||||
}
|
||||
|
||||
public async getLatestMatchingReleaseAsync(identifier: string): Promise<ReleaseResponse> {
|
||||
const { name } = this.parseIdentifier(identifier) ?? {};
|
||||
if (!name) return { success: false, error: { code: "invalidIdentifier" } };
|
||||
|
||||
const releasesResponse = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/images"));
|
||||
if (!releasesResponse.ok) {
|
||||
return { success: false, error: { code: "unexpected", message: releasesResponse.statusText } };
|
||||
}
|
||||
|
||||
const releasesResponseJson: unknown = await releasesResponse.json();
|
||||
const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson);
|
||||
if (!success) {
|
||||
return { success: false, error: { code: "unexpected", message: error.message } };
|
||||
}
|
||||
|
||||
const release = data.data.repositories.linuxserver.find((repo) => repo.name === name);
|
||||
if (!release) {
|
||||
logger.warn("Repository not found on provider", {
|
||||
name,
|
||||
});
|
||||
return { success: false, error: { code: "noMatchingVersion" } };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
latestRelease: release.version,
|
||||
latestReleaseAt: release.version_timestamp,
|
||||
releaseDescription: release.changelog?.shift()?.desc,
|
||||
projectUrl: release.github_url,
|
||||
projectDescription: release.description,
|
||||
isArchived: release.deprecated,
|
||||
createdAt: release.initial_date ? new Date(release.initial_date) : undefined,
|
||||
starsCount: release.stars,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const releasesResponseSchema = z.object({
|
||||
data: z.object({
|
||||
repositories: z.object({
|
||||
linuxserver: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
initial_date: z
|
||||
.string()
|
||||
.transform((value) => new Date(value))
|
||||
.optional(),
|
||||
github_url: z.string(),
|
||||
description: z.string(),
|
||||
version: z.string(),
|
||||
version_timestamp: z.string().transform((value) => new Date(value)),
|
||||
stars: z.number(),
|
||||
deprecated: z.boolean(),
|
||||
changelog: z
|
||||
.array(
|
||||
z.object({
|
||||
date: z.string().transform((value) => new Date(value)),
|
||||
desc: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
|
||||
import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
|
||||
import type { ReleaseResponse } from "../interfaces/releases-providers/releases-providers-types";
|
||||
import { releasesResponseSchema } from "./npm-schemas";
|
||||
|
||||
export class NPMIntegration extends Integration implements ReleasesProviderIntegration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(this.url("/"));
|
||||
|
||||
if (!response.ok) {
|
||||
return TestConnectionError.StatusResult(response);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise<ReleaseResponse> {
|
||||
if (!identifier) return { success: false, error: { code: "invalidIdentifier" } };
|
||||
|
||||
const releasesResponse = await fetchWithTrustedCertificatesAsync(this.url(`/${encodeURIComponent(identifier)}`));
|
||||
if (!releasesResponse.ok) {
|
||||
return { success: false, error: { code: "unexpected", message: releasesResponse.statusText } };
|
||||
}
|
||||
|
||||
const releasesResponseJson: unknown = await releasesResponse.json();
|
||||
const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson);
|
||||
if (!success) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: "unexpected",
|
||||
message: releasesResponseJson ? JSON.stringify(releasesResponseJson, null, 2) : error.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const formattedReleases = data.time.map((tag) => ({
|
||||
...tag,
|
||||
releaseUrl: `https://www.npmjs.com/package/${encodeURIComponent(data.name)}/v/${encodeURIComponent(tag.latestRelease)}`,
|
||||
releaseDescription: data.versions[tag.latestRelease]?.description ?? "",
|
||||
}));
|
||||
|
||||
const latestRelease = getLatestRelease(formattedReleases, versionRegex);
|
||||
if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } };
|
||||
|
||||
return { success: true, data: latestRelease };
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const releasesResponseSchema = z.object({
|
||||
time: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.string().transform((value) => new Date(value)),
|
||||
)
|
||||
.transform((version) =>
|
||||
Object.entries(version).map(([key, value]) => ({
|
||||
latestRelease: key,
|
||||
latestReleaseAt: value,
|
||||
})),
|
||||
),
|
||||
versions: z.record(z.string(), z.object({ description: z.string() })),
|
||||
name: z.string(),
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
import type { RequestInit, Response } from "undici";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
|
||||
import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
|
||||
import type {
|
||||
ReleaseProviderResponse,
|
||||
ReleaseResponse,
|
||||
} from "../interfaces/releases-providers/releases-providers-types";
|
||||
import { releasesResponseSchema } from "./quay-schemas";
|
||||
|
||||
const logger = createLogger({ module: "quayIntegration" });
|
||||
|
||||
export class QuayIntegration extends Integration implements ReleasesProviderIntegration {
|
||||
private async withHeadersAsync(callback: (headers: RequestInit["headers"]) => Promise<Response>): Promise<Response> {
|
||||
if (!this.hasSecretValue("personalAccessToken")) return await callback(undefined);
|
||||
|
||||
return await callback({
|
||||
Authorization: `token ${this.getSecretValue("personalAccessToken")}`,
|
||||
});
|
||||
}
|
||||
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await this.withHeadersAsync(async (headers) => {
|
||||
return await input.fetchAsync(this.url("/api/v1/discovery"), {
|
||||
headers,
|
||||
});
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return TestConnectionError.StatusResult(response);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
private parseIdentifier(identifier: string) {
|
||||
const [owner, name] = identifier.split("/");
|
||||
if (!owner || !name) {
|
||||
logger.warn("Invalid identifier format. Expected 'owner/name' for identifier", {
|
||||
identifier,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return { owner, name };
|
||||
}
|
||||
|
||||
public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise<ReleaseResponse> {
|
||||
const parsedIdentifier = this.parseIdentifier(identifier);
|
||||
if (!parsedIdentifier) return { success: false, error: { code: "invalidIdentifier" } };
|
||||
|
||||
const { owner, name } = parsedIdentifier;
|
||||
|
||||
const releasesResponse = await this.withHeadersAsync(async (headers) => {
|
||||
return await fetchWithTrustedCertificatesAsync(
|
||||
this.url(
|
||||
`/api/v1/repository/${encodeURIComponent(owner)}/${encodeURIComponent(name)}?includeTags=true&includeStats=true`,
|
||||
),
|
||||
{
|
||||
headers,
|
||||
},
|
||||
);
|
||||
});
|
||||
if (!releasesResponse.ok) {
|
||||
return { success: false, error: { code: "unexpected", message: releasesResponse.statusText } };
|
||||
}
|
||||
|
||||
const releasesResponseJson: unknown = await releasesResponse.json();
|
||||
const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson);
|
||||
if (!success) {
|
||||
return { success: false, error: { code: "unexpected", message: error.message } };
|
||||
}
|
||||
|
||||
const releasesProviderResponse = Object.entries(data.tags).reduce<ReleaseProviderResponse[]>((acc, [_, tag]) => {
|
||||
if (!tag.name || !tag.last_modified) return acc;
|
||||
acc.push({
|
||||
latestRelease: tag.name,
|
||||
latestReleaseAt: new Date(tag.last_modified),
|
||||
releaseUrl: `https://quay.io/repository/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/tag/${encodeURIComponent(tag.name)}`,
|
||||
});
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const latestRelease = getLatestRelease(releasesProviderResponse, versionRegex);
|
||||
if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } };
|
||||
|
||||
return { success: true, data: { projectDescription: data.description, ...latestRelease } };
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const releasesResponseSchema = z.object({
|
||||
description: z.string().optional(),
|
||||
tags: z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
name: z.string(),
|
||||
last_modified: z.string(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -1,141 +0,0 @@
|
||||
import z from "zod";
|
||||
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type { TimetableIntegration } from "../interfaces/timetable/timetable-integration";
|
||||
import type { Station, Timetable, TimetableOptions } from "../interfaces/timetable/timetable-types";
|
||||
|
||||
const supportedStationTypes = ["bus", "tram", "train", "ship", "cablecar", "funicular", "chairlift"];
|
||||
|
||||
export class SearchChIntegration extends Integration implements TimetableIntegration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(this.url("/timetable/api/completion.json", { term: "Zürich" }));
|
||||
|
||||
if (!response.ok) {
|
||||
return TestConnectionError.StatusResult(response);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for stations matching the given query.
|
||||
* @param query The search query for station names.
|
||||
* @returns A list of stations matching the search query.
|
||||
* @docs https://search.ch/fahrplan/api/help
|
||||
*/
|
||||
public async searchStationsAsync(query: string): Promise<Station[]> {
|
||||
const response = await fetchWithTrustedCertificatesAsync(
|
||||
this.url("/timetable/api/completion.json", {
|
||||
term: query,
|
||||
show_ids: 1,
|
||||
nofavorites: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
|
||||
const data = await searchSchema.parseAsync(await response.json());
|
||||
|
||||
return (
|
||||
data
|
||||
// Remove completions like addresses, we only want stations.
|
||||
.filter((item) => supportedStationTypes.some((type) => item.iconclass.endsWith(type)))
|
||||
.map((item) => (item.id !== undefined ? { id: item.id, name: item.label } : null))
|
||||
.filter((item) => item !== null)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the departure timetable for a given station.
|
||||
* @param options Timetable options including station ID and limit.
|
||||
* @returns timetable for the given station.
|
||||
* @docs https://search.ch/fahrplan/api/help
|
||||
*/
|
||||
public async getTimetableAsync(options: TimetableOptions): Promise<Timetable> {
|
||||
const now = new Date();
|
||||
// The following format is used [01.01.2000, 23:59:00]. The API allows usage of DD.MM.YYYY format and HH:mm format
|
||||
const [date, time] = now.toLocaleString("en-CH", { timeZone: "Europe/Zurich" }).split(", ") as [string, string];
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(
|
||||
this.url("/timetable/api/stationboard.json", {
|
||||
stop: options.stationId,
|
||||
limit: options.limit,
|
||||
show_delays: 1,
|
||||
show_tracks: 1,
|
||||
date,
|
||||
time: time.substring(0, 5), // Only keep the HH:mm part of the time, as that's what the API expects
|
||||
}),
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
|
||||
const data = await timetableSchema.parseAsync(await response.json());
|
||||
|
||||
return {
|
||||
stationId: options.stationId,
|
||||
timestamp: now,
|
||||
entries: data.connections.map((connection) => {
|
||||
// Color is in format A35~F10~ for foreground and background color. Some only have ~~ and therefore no color
|
||||
const color = connection.color.split("~")[0];
|
||||
|
||||
return {
|
||||
timestamp: connection.time,
|
||||
line: connection.line
|
||||
? {
|
||||
name: connection.line,
|
||||
color: color && color.length >= 1 ? `#${color}` : null,
|
||||
}
|
||||
: null,
|
||||
location: connection.terminal.name,
|
||||
delay: connection.dep_delay,
|
||||
platform: connection.track
|
||||
? {
|
||||
// If the track name contains "!", it indicates a change in the track
|
||||
// so we remove the "!" from the name and set hasChanged to true
|
||||
name: connection.track.replace("!", ""),
|
||||
hasChanged: connection.track.includes("!"),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const timetableSchema = z.object({
|
||||
connections: z.array(
|
||||
z.object({
|
||||
time: z.string().transform((str) => new Date(str)),
|
||||
line: z.string().optional(),
|
||||
color: z.string(),
|
||||
terminal: z.object({
|
||||
name: z.string(),
|
||||
}),
|
||||
track: z.string().optional(),
|
||||
dep_delay: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((str) => (str ? parseInt(str) : 0)),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const searchSchema = z.array(
|
||||
z.object({
|
||||
id: z.string().optional(),
|
||||
label: z.string(),
|
||||
iconclass: z.string(), // This is in format sl-icon-type-{type}
|
||||
}),
|
||||
);
|
||||
@@ -15,7 +15,6 @@ export * from "./opnsense/opnsense-types";
|
||||
export * from "./interfaces/media-releases";
|
||||
export * from "./coolify/coolify-types";
|
||||
export * from "./anchor/anchor-types";
|
||||
export * from "./interfaces/timetable/timetable-types";
|
||||
export * from "./tracearr/tracearr-types";
|
||||
export * from "./speedtest-tracker/speedtest-tracker-types";
|
||||
export * from "./uptime-kuma/uptime-kuma-types";
|
||||
|
||||
Reference in new issue
Block a user