Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/webhook-forwarder/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.dev.vars
.wrangler/
dist/
node_modules/
20 changes: 20 additions & 0 deletions apps/webhook-forwarder/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "@immich-services/webhook-forwarder",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"build": "wrangler deploy --dry-run --outdir ../../dist/webhook-forwarder",
"tail": "wrangler tail",
"test": "vitest",
"check": "tsc --noEmit"
},
"dependencies": {
"@noble/hashes": "^2.0.1",
"octokit": "^5.0.5"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20251014.0"
}
}
64 changes: 64 additions & 0 deletions apps/webhook-forwarder/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { hmac } from '@noble/hashes/hmac.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
import { App } from 'octokit';

export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);

if (url.pathname === '/webhook/outline' && request.method === 'POST') {
try {
const payload = await request.text();

if (env.SKIP_WEBHOOK_VALIDATION !== 'true') {
const messageSignature = request.headers.get('Outline-Signature');
if (!messageSignature) {
return new Response('invalid signature', { status: 401 });
}
const computedSignature = bytesToHex(
hmac(sha256, utf8ToBytes(env.OUTLINE_WEBHOOK_SECRET), utf8ToBytes(payload)),
);

if (computedSignature !== messageSignature) {
return new Response('invalid signature', { status: 401 });
}
}

const githubClient = await new App({
appId: env.GITHUB_APP_ID,
privateKey: env.GITHUB_APP_PRIVATE_KEY,
}).getInstallationOctokit(Number.parseInt(env.GITHUB_INSTALLATION_ID));

await githubClient.rest.repos.createDispatchEvent({
owner: 'immich-app',
repo: 'static-pages',
event_type: 'outline-webhook',
client_payload: { payload },
});

return new Response(
JSON.stringify({
success: true,
}),
{
headers: { 'Content-Type': 'application/json' },
},
);
} catch (error) {
console.error('Error processing webhook:', error);
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : 'Unknown error',
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
},
);
}
}

return new Response('Not Found', { status: 404 });
},
};
8 changes: 8 additions & 0 deletions apps/webhook-forwarder/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": ["@cloudflare/workers-types", "@cloudflare/vitest-pool-workers"],
"esModuleInterop": true
},
"include": ["src/**/*", "worker-configuration.d.ts"]
}
1 change: 1 addition & 0 deletions apps/webhook-forwarder/tsconfig.tsbuildinfo

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions apps/webhook-forwarder/worker-configuration.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
interface Env {
// Outline
OUTLINE_WEBHOOK_SECRET: string;

// GitHub App
GITHUB_APP_ID: string;
GITHUB_APP_PRIVATE_KEY: string;
GITHUB_INSTALLATION_ID: string;

// Development (explicitly set to "true" in .dev.vars to skip webhook validation)
SKIP_WEBHOOK_VALIDATION?: string;
}
3 changes: 3 additions & 0 deletions apps/webhook-forwarder/wrangler.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
name = "webhook-forwarder-immich-app"
main = "src/index.ts"
compatibility_date = "2024-01-01"
11 changes: 11 additions & 0 deletions deployment/modules/cloudflare/workers/webhook-forwarder/config.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
terraform {
backend "pg" {}
required_version = "~> 1.7"

required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 5"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
locals {
resource_stage = var.stage != "" ? "-${var.stage}" : ""
resource_env = "-${var.env}"
resource_suffix = "${local.resource_env}${local.resource_stage}"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
provider "cloudflare" {
api_token = data.terraform_remote_state.api_keys_state.outputs.terraform_key_cloudflare_account
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
variable "tf_state_postgres_conn_str" {
description = "PostgreSQL connection string for Terraform state"
type = string
}

data "terraform_remote_state" "api_keys_state" {
backend = "pg"

config = {
conn_str = var.tf_state_postgres_conn_str
schema_name = "prod_cloudflare_api_keys"
}
}

data "terraform_remote_state" "cloudflare_account" {
backend = "pg"

config = {
conn_str = var.tf_state_postgres_conn_str
schema_name = "prod_cloudflare_account"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
terraform {
source = "."

extra_arguments custom_vars {
commands = get_terraform_commands_that_need_vars()
}
}

include {
path = find_in_parent_folders("state.hcl")
}

locals {
env = get_env("TF_VAR_env")
stage = get_env("TF_VAR_stage")
app_name = "blog-importer"
}

inputs = {
app_name = local.app_name
}

remote_state {
backend = "pg"

config = {
conn_str = get_env("TF_VAR_tf_state_postgres_conn_str")
schema_name = "services_cf_workers_${local.app_name}_${local.env}${local.stage}"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
variable "stage" {}
variable "env" {}
variable "app_name" {}
variable "cloudflare_account_id" {}
variable "dist_dir" {}

variable "outline_webhook_secret" {
description = "Secret for verifying Outline webhook signatures"
type = string
sensitive = true
}

variable "github_app_id" {
description = "GitHub App ID"
type = string
}

variable "github_app_private_key" {
description = "GitHub App private key (PEM format)"
type = string
sensitive = true
}

variable "github_installation_id" {
description = "GitHub App installation ID for the immich organization"
type = string
}
89 changes: 89 additions & 0 deletions deployment/modules/cloudflare/workers/webhook-forwarder/worker.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
resource "cloudflare_worker" "worker" {
account_id = var.cloudflare_account_id
name = "${var.app_name}-api${local.resource_suffix}"
logpush = true
}

resource "terraform_data" "source_hash" {
input = filesha256("${var.dist_dir}/${var.app_name}/index.js")
}

resource "cloudflare_worker_version" "worker" {
account_id = var.cloudflare_account_id
worker_id = cloudflare_worker.worker.id
bindings = [
{
name = "OUTLINE_WEBHOOK_SECRET"
type = "secret_text"
text = var.outline_webhook_secret
},
{
name = "GITHUB_APP_ID"
type = "plain_text"
text = var.github_app_id
},
{
name = "GITHUB_APP_PRIVATE_KEY"
type = "secret_text"
text = var.github_app_private_key
},
{
name = "GITHUB_INSTALLATION_ID"
type = "plain_text"
text = var.github_installation_id
}
]
compatibility_date = "2024-01-01"
main_module = "index.js"
modules = [
{
content_file = "${var.dist_dir}/${var.app_name}/index.js"
content_type = "application/javascript+module"
name = "index.js"
}
]
lifecycle {
replace_triggered_by = [
terraform_data.source_hash
]
}
}

resource "cloudflare_workers_deployment" "worker" {
account_id = var.cloudflare_account_id
script_name = cloudflare_worker.worker.name
strategy = "percentage"
versions = [
{
percentage = 100
version_id = cloudflare_worker_version.worker.id
}
]
}

data "cloudflare_zone" "immich_app" {
filter = {
name = "immich.app"
}
}

resource "cloudflare_workers_custom_domain" "worker" {
account_id = var.cloudflare_account_id
environment = "production"
hostname = module.domain.fqdn
service = cloudflare_worker.worker.name
zone_id = data.cloudflare_zone.immich_app.zone_id
}

module "domain" {
source = "git::https://github.com/immich-app/devtools.git//tf/shared/modules/domain?ref=main"

app_name = var.app_name
stage = var.stage
env = var.env
domain = "immich.app"
}

output "webhook_url" {
value = "https://${module.domain.fqdn}/webhook"
}
Loading
Loading