kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at cd7cada2f86b4e866a15b4323bb8d6d7ab5bba8b 191 lines 4.8 kB view raw
1import { lookup } from "node:dns/promises"; 2import net from "node:net"; 3import * as v from "valibot"; 4 5function isDisallowedIpv4(ip: string): boolean { 6 const parts = ip.split(".").map((part) => Number.parseInt(part, 10)); 7 if (parts.length !== 4 || parts.some(Number.isNaN)) { 8 return true; 9 } 10 11 const [a, b] = parts; 12 13 return ( 14 a === 0 || 15 a === 10 || 16 a === 127 || 17 (a === 169 && b === 254) || 18 (a === 172 && b >= 16 && b <= 31) || 19 (a === 192 && b === 168) 20 ); 21} 22 23function isDisallowedIpv6(ip: string): boolean { 24 const normalized = ip.toLowerCase(); 25 26 return ( 27 normalized === "::" || 28 normalized === "::1" || 29 normalized.startsWith("fe8") || 30 normalized.startsWith("fe9") || 31 normalized.startsWith("fea") || 32 normalized.startsWith("feb") || 33 normalized.startsWith("fc") || 34 normalized.startsWith("fd") 35 ); 36} 37 38function isDisallowedAddress(address: string): boolean { 39 if (address === "localhost") { 40 return true; 41 } 42 43 const version = net.isIP(address); 44 if (version === 4) { 45 return isDisallowedIpv4(address); 46 } 47 48 if (version === 6) { 49 return isDisallowedIpv6(address); 50 } 51 52 return false; 53} 54 55export async function assertPublicWebhookDestination( 56 webhookUrl: string, 57): Promise<void> { 58 const url = new URL(webhookUrl); 59 60 if (!["http:", "https:"].includes(url.protocol)) { 61 throw new Error("Generic webhook URL must use http or https"); 62 } 63 64 if (isDisallowedAddress(url.hostname)) { 65 throw new Error( 66 "Generic webhook destination resolves to a non-routable address", 67 ); 68 } 69 70 const addresses = await lookup(url.hostname, { all: true, verbatim: true }); 71 if (addresses.length === 0) { 72 throw new Error("Generic webhook destination could not be resolved"); 73 } 74 75 if (addresses.some((entry) => isDisallowedAddress(entry.address))) { 76 throw new Error( 77 "Generic webhook destination resolves to a non-routable address", 78 ); 79 } 80} 81 82export const genericWebhookEventKeys = [ 83 "taskCreated", 84 "taskStatusChanged", 85 "taskPriorityChanged", 86 "taskTitleChanged", 87 "taskDescriptionChanged", 88 "taskCommentCreated", 89] as const; 90 91export type GenericWebhookEventKey = (typeof genericWebhookEventKeys)[number]; 92 93export const genericWebhookConfigSchema = v.object({ 94 webhookUrl: v.pipe( 95 v.string(), 96 v.url(), 97 v.check((value) => { 98 const protocol = new URL(value).protocol; 99 return protocol === "http:" || protocol === "https:"; 100 }, "Webhook URL must use http or https"), 101 ), 102 secret: v.optional(v.string()), 103 health: v.optional( 104 v.object({ 105 lastSuccessAt: v.optional(v.string()), 106 lastFailureAt: v.optional(v.string()), 107 lastFailureMessage: v.optional(v.string()), 108 failureCount: v.optional(v.number()), 109 lastAttempt: v.optional( 110 v.object({ 111 eventName: v.string(), 112 taskId: v.string(), 113 projectId: v.string(), 114 webhookUrl: v.string(), 115 }), 116 ), 117 }), 118 ), 119 events: v.optional( 120 v.object({ 121 taskCreated: v.optional(v.boolean()), 122 taskStatusChanged: v.optional(v.boolean()), 123 taskPriorityChanged: v.optional(v.boolean()), 124 taskTitleChanged: v.optional(v.boolean()), 125 taskDescriptionChanged: v.optional(v.boolean()), 126 taskCommentCreated: v.optional(v.boolean()), 127 }), 128 ), 129}); 130 131export type GenericWebhookConfig = v.InferOutput< 132 typeof genericWebhookConfigSchema 133>; 134 135export const defaultGenericWebhookEvents: Record< 136 GenericWebhookEventKey, 137 boolean 138> = { 139 taskCreated: true, 140 taskStatusChanged: true, 141 taskPriorityChanged: false, 142 taskTitleChanged: false, 143 taskDescriptionChanged: false, 144 taskCommentCreated: true, 145}; 146 147export function normalizeGenericWebhookConfig( 148 config: GenericWebhookConfig, 149): GenericWebhookConfig { 150 const secret = 151 typeof config.secret === "string" 152 ? config.secret.trim() || undefined 153 : undefined; 154 155 return { 156 ...config, 157 secret, 158 health: config.health 159 ? { 160 ...config.health, 161 failureCount: config.health.failureCount ?? 0, 162 } 163 : undefined, 164 events: { 165 ...defaultGenericWebhookEvents, 166 ...(config.events ?? {}), 167 }, 168 }; 169} 170 171export async function validateGenericWebhookConfig( 172 config: unknown, 173): Promise<{ valid: boolean; errors?: string[] }> { 174 try { 175 const parsed = v.parse(genericWebhookConfigSchema, config); 176 await assertPublicWebhookDestination(parsed.webhookUrl); 177 return { valid: true }; 178 } catch (error) { 179 if (error instanceof v.ValiError) { 180 return { 181 valid: false, 182 errors: error.issues.map((issue) => issue.message), 183 }; 184 } 185 186 return { 187 valid: false, 188 errors: [error instanceof Error ? error.message : "Invalid config"], 189 }; 190 } 191}