mirror of
https://github.com/immich-app/static-pages.git
synced 2026-09-30 13:23:05 +08:00
feat: add survey app (#422)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Zack Pollard <zackpollard@Zacks-MBP.local>
This commit is contained in:
co-authored by
Claude Opus 4.6
Zack Pollard
parent
cb47e2344a
commit
f4b32da07a
@@ -2,3 +2,10 @@ export TF_VAR_cloudflare_account_id="op://tf/cloudflare/account_id"
|
||||
export TF_VAR_cloudflare_api_token="op://tf/cloudflare/api_token"
|
||||
export TF_VAR_tf_state_postgres_conn_str="op://tf/tf_state/postgres_conn_str"
|
||||
export TF_VAR_env=$ENVIRONMENT
|
||||
|
||||
export TF_VAR_oidc_issuer="https://auth.internal.futo.org"
|
||||
export TF_VAR_oidc_client_id="op://tf/FUTO_ZITADEL_OAUTH_CLIENT_ID_SURVEY/password"
|
||||
export TF_VAR_oidc_client_secret="op://tf/FUTO_ZITADEL_OAUTH_CLIENT_SECRET_SURVEY/password"
|
||||
|
||||
export TF_VAR_oidc_client_id_dev="op://tf/FUTO_ZITADEL_OAUTH_CLIENT_ID_SURVEY_DEV/password"
|
||||
export TF_VAR_oidc_client_secret_dev="op://tf/FUTO_ZITADEL_OAUTH_CLIENT_SECRET_SURVEY_DEV/password"
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# This file is maintained automatically by "tofu init".
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.opentofu.org/cloudflare/cloudflare" {
|
||||
version = "5.18.0"
|
||||
constraints = "~> 5.0"
|
||||
hashes = [
|
||||
"h1:B9eoAx4QKNVuKHDahNl8JzuSLCCeIGAJiS0MckJu5wQ=",
|
||||
"zh:47e7bdfd8eddd2685f383269c0b6936ef62edd6d8383c8d7757b0cce0a689737",
|
||||
"zh:aa23eb6aa128667883cabc449ceca4072d0181f574cd727e08ebd6d69a4bfd48",
|
||||
"zh:c3da673e05d3bd933c82e2b6ba0f85aa23c5e24fadd3932f7c066314feeb65a3",
|
||||
"zh:c59f07c017fc78b79e80554a0737c9db2a2e681c3e46ff637942d28d1f1a3924",
|
||||
"zh:d559074612835a37fa684d8d7d0cf68911487b71f4067acc59069cb00bb8baf0",
|
||||
"zh:e12290a4eda757c183a4258230245dd170f0def389c37eb771db144ce3b382dd",
|
||||
"zh:ed47e484432ba1bbbb4802061f395ebd253ae8e20be9b72552d3d830fd2ca268",
|
||||
"zh:f35e08d468408697b3e7c4a7f548b874141ac8f8d395ab8edded322201cc7047",
|
||||
"zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.opentofu.org/hashicorp/null" {
|
||||
version = "3.2.4"
|
||||
hashes = [
|
||||
"h1:jsKjBiLb+v3OIC3xuDiY4sR0r1OHUMSWPYKult9MhT0=",
|
||||
"zh:1769783386610bed8bb1e861a119fe25058be41895e3996d9216dd6bb8a7aee3",
|
||||
"zh:32c62a9387ad0b861b5262b41c5e9ed6e940eda729c2a0e58100e6629af27ddb",
|
||||
"zh:339bf8c2f9733fce068eb6d5612701144c752425cebeafab36563a16be460fb2",
|
||||
"zh:36731f23343aee12a7e078067a98644c0126714c4fe9ac930eecb0f2361788c4",
|
||||
"zh:3d106c7e32a929e2843f732625a582e562ff09120021e510a51a6f5d01175b8d",
|
||||
"zh:74bcb3567708171ad83b234b92c9d63ab441ef882b770b0210c2b14fdbe3b1b6",
|
||||
"zh:90b55bdbffa35df9204282251059e62c178b0ac7035958b93a647839643c0072",
|
||||
"zh:ae24c0e5adc692b8f94cb23a000f91a316070fdc19418578dcf2134ff57cf447",
|
||||
"zh:b5c10d4ad860c4c21273203d1de6d2f0286845edf1c64319fa2362df526b5f58",
|
||||
"zh:e05bbd88e82e1d6234988c85db62fd66f11502645838fff594a2ec25352ecd80",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.opentofu.org/hashicorp/random" {
|
||||
version = "3.8.1"
|
||||
constraints = ">= 3.0.0"
|
||||
hashes = [
|
||||
"h1:EHn3jsqOKhWjbg0X+psk0Ww96yz3N7ASqEKKuFvDFwo=",
|
||||
"zh:25c458c7c676f15705e872202dad7dcd0982e4a48e7ea1800afa5fc64e77f4c8",
|
||||
"zh:2edeaf6f1b20435b2f81855ad98a2e70956d473be9e52a5fdf57ccd0098ba476",
|
||||
"zh:44becb9d5f75d55e36dfed0c5beabaf4c92e0a2bc61a3814d698271c646d48e7",
|
||||
"zh:7699032612c3b16cc69928add8973de47b10ce81b1141f30644a0e8a895b5cd3",
|
||||
"zh:86d07aa98d17703de9fbf402c89590dc1e01dbe5671dd6bc5e487eb8fe87eee0",
|
||||
"zh:8c411c77b8390a49a8a1bc9f176529e6b32369dd33a723606c8533e5ca4d68c1",
|
||||
"zh:a5ecc8255a612652a56b28149994985e2c4dc046e5d34d416d47fa7767f5c28f",
|
||||
"zh:aea3fe1a5669b932eda9c5c72e5f327db8da707fe514aaca0d0ef60cb24892f9",
|
||||
"zh:f56e26e6977f755d7ae56fa6320af96ecf4bb09580d47cb481efbf27f1c5afff",
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# Temporary: destroy orphaned analytics resources from state.
|
||||
# Remove this file, the token_creator provider, and cloudflare_api_token variable
|
||||
# after the first successful apply.
|
||||
|
||||
removed {
|
||||
from = cloudflare_api_token.analytics_read
|
||||
lifecycle {
|
||||
destroy = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
terraform {
|
||||
backend "pg" {}
|
||||
required_version = "~> 1.7"
|
||||
|
||||
required_providers {
|
||||
cloudflare = {
|
||||
source = "cloudflare/cloudflare"
|
||||
version = "~> 5.0"
|
||||
}
|
||||
random = {
|
||||
source = "hashicorp/random"
|
||||
version = ">= 3.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
resource "cloudflare_d1_database" "survey" {
|
||||
account_id = var.cloudflare_account_id
|
||||
name = "survey${local.resource_suffix}"
|
||||
|
||||
read_replication = {
|
||||
mode = "disabled"
|
||||
}
|
||||
}
|
||||
|
||||
resource "null_resource" "d1_migrations" {
|
||||
triggers = {
|
||||
migrations_hash = sha256(join("", [for f in fileset(var.migrations_dir, "*.sql") : filesha256("${var.migrations_dir}/${f}")]))
|
||||
}
|
||||
|
||||
provisioner "local-exec" {
|
||||
# Re-running an already-applied migration is benign ("already exists" /
|
||||
# "duplicate column"); every other error must exit non-zero so the failure is
|
||||
# visible in the workflow instead of being masked.
|
||||
command = <<-EOT
|
||||
set -eo pipefail
|
||||
for f in $(ls ${var.migrations_dir}/*.sql | sort); do
|
||||
echo "Applying migration: $f"
|
||||
response=$(curl -sS -X POST \
|
||||
"https://api.cloudflare.com/client/v4/accounts/${var.cloudflare_account_id}/d1/database/${cloudflare_d1_database.survey.id}/query" \
|
||||
-H "Authorization: Bearer ${data.terraform_remote_state.api_keys_state.outputs.terraform_key_cloudflare_account}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"sql\": $(cat "$f" | jq -Rs .)}")
|
||||
|
||||
success=$(echo "$response" | jq -r '.success // false')
|
||||
if [ "$success" = "true" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
errors=$(echo "$response" | jq -r '.errors // [] | map(.message) | join("; ")')
|
||||
if echo "$errors" | grep -qiE "already exists|duplicate column"; then
|
||||
echo " (already applied): $errors"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "Migration $f failed:" >&2
|
||||
echo "$response" | jq . >&2
|
||||
exit 1
|
||||
done
|
||||
EOT
|
||||
}
|
||||
|
||||
depends_on = [cloudflare_d1_database.survey]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
locals {
|
||||
resource_stage = var.stage != "" ? "-${var.stage}" : ""
|
||||
resource_env = "-${var.env}"
|
||||
resource_suffix = "${local.resource_env}${local.resource_stage}"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
provider "cloudflare" {
|
||||
api_token = data.terraform_remote_state.api_keys_state.outputs.terraform_key_cloudflare_account
|
||||
}
|
||||
|
||||
# Temporary: needed to destroy orphaned analytics token resource from state
|
||||
provider "cloudflare" {
|
||||
alias = "token_creator"
|
||||
api_token = var.cloudflare_api_token
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
resource "random_password" "session_secret" {
|
||||
length = 64
|
||||
special = false
|
||||
}
|
||||
|
||||
resource "random_password" "password_secret" {
|
||||
length = 64
|
||||
special = false
|
||||
}
|
||||
|
||||
resource "random_password" "admin_setup_token" {
|
||||
length = 48
|
||||
special = false
|
||||
}
|
||||
|
||||
output "admin_setup_token" {
|
||||
description = "Token required in the X-Setup-Token header when claiming the admin account."
|
||||
value = random_password.admin_setup_token.result
|
||||
sensitive = true
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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 = replace(get_env("TF_VAR_app_name"), "-", "_")
|
||||
}
|
||||
|
||||
remote_state {
|
||||
backend = "pg"
|
||||
|
||||
config = {
|
||||
conn_str = get_env("TF_VAR_tf_state_postgres_conn_str")
|
||||
schema_name = "cloudflare_workers_immich_app_${local.app_name}_${local.env}${local.stage}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
variable "tf_state_postgres_conn_str" {}
|
||||
variable "stage" {}
|
||||
variable "env" {}
|
||||
variable "app_name" {}
|
||||
variable "cloudflare_account_id" {}
|
||||
variable "dist_dir" {}
|
||||
variable "migrations_dir" {}
|
||||
|
||||
# Leave these empty to disable OIDC.
|
||||
variable "oidc_issuer" {
|
||||
description = "OIDC provider issuer URL (e.g., https://auth.example.com/realms/immich)"
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "oidc_client_id" {
|
||||
description = "OIDC client ID"
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "oidc_client_secret" {
|
||||
description = "OIDC client secret"
|
||||
type = string
|
||||
sensitive = true
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "oidc_client_id_dev" {
|
||||
description = "OIDC client ID of the dev_mode application used by non-production stages"
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "oidc_client_secret_dev" {
|
||||
description = "OIDC client secret of the dev_mode application used by non-production stages"
|
||||
type = string
|
||||
sensitive = true
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "oidc_role_claim" {
|
||||
description = "Claim containing the user's role. Zitadel flattens project roles into a top-level `role` claim (a single string); it never emits `groups`."
|
||||
type = string
|
||||
default = "role"
|
||||
}
|
||||
|
||||
variable "oidc_role_map_admin" {
|
||||
description = "Claim value that maps to admin role"
|
||||
type = string
|
||||
default = "survey-admin"
|
||||
}
|
||||
|
||||
variable "oidc_role_map_editor" {
|
||||
description = "Claim value that maps to editor role"
|
||||
type = string
|
||||
default = "survey-editor"
|
||||
}
|
||||
|
||||
# Temporary: needed to destroy orphaned analytics token resource
|
||||
variable "cloudflare_api_token" {
|
||||
description = "Cloudflare API token (temporary, remove after analytics token is destroyed from state)"
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
locals {
|
||||
is_production = var.env == "prod" && var.stage == ""
|
||||
|
||||
# Production and preview authenticate against different Zitadel applications.
|
||||
# Preview hostnames are per-PR and unbounded, so they can only be registered
|
||||
# as a glob, which Zitadel permits only on a dev_mode application — and
|
||||
# dev_mode also drops the https requirement, so it must not be enabled on the
|
||||
# application production uses.
|
||||
oidc_client_id = local.is_production ? var.oidc_client_id : var.oidc_client_id_dev
|
||||
oidc_client_secret = local.is_production ? var.oidc_client_secret : var.oidc_client_secret_dev
|
||||
|
||||
# All-or-nothing: a half-populated config yields a worker that advertises
|
||||
# oidcEnabled and then builds an authorize URL with an empty redirect_uri.
|
||||
oidc_enabled = var.oidc_issuer != "" && local.oidc_client_id != "" && local.oidc_client_secret != ""
|
||||
|
||||
# Derived so it always matches the hostname this stage actually serves.
|
||||
oidc_redirect_uri = "https://${module.domain.fqdn}/api/auth/callback"
|
||||
|
||||
oidc_bindings = concat(
|
||||
local.oidc_enabled ? [
|
||||
{
|
||||
name = "OIDC_ISSUER"
|
||||
type = "plain_text"
|
||||
text = var.oidc_issuer
|
||||
},
|
||||
{
|
||||
name = "OIDC_CLIENT_ID"
|
||||
type = "plain_text"
|
||||
text = local.oidc_client_id
|
||||
},
|
||||
{
|
||||
name = "OIDC_CLIENT_SECRET"
|
||||
type = "secret_text"
|
||||
text = local.oidc_client_secret
|
||||
},
|
||||
{
|
||||
name = "OIDC_REDIRECT_URI"
|
||||
type = "plain_text"
|
||||
text = local.oidc_redirect_uri
|
||||
},
|
||||
] : [],
|
||||
# Only production goes SSO-only. Previews keep password auth alongside OIDC
|
||||
# so a misconfigured claim leaves the stage debuggable instead of shut.
|
||||
local.oidc_enabled && local.is_production ? [
|
||||
{
|
||||
name = "DISABLE_PASSWORD_AUTH"
|
||||
type = "plain_text"
|
||||
text = "true"
|
||||
},
|
||||
] : [],
|
||||
)
|
||||
|
||||
api_bindings = concat(
|
||||
[
|
||||
{
|
||||
name = "DB"
|
||||
type = "d1"
|
||||
id = cloudflare_d1_database.survey.id
|
||||
},
|
||||
{
|
||||
name = "SURVEY_SESSIONS"
|
||||
type = "durable_object_namespace"
|
||||
class_name = "SurveyDO"
|
||||
script_name = cloudflare_worker.sessions.name
|
||||
},
|
||||
{
|
||||
name = "SESSION_SECRET"
|
||||
type = "secret_text"
|
||||
text = random_password.session_secret.result
|
||||
},
|
||||
{
|
||||
name = "PASSWORD_SECRET"
|
||||
type = "secret_text"
|
||||
text = random_password.password_secret.result
|
||||
},
|
||||
{
|
||||
name = "ADMIN_SETUP_TOKEN"
|
||||
type = "secret_text"
|
||||
text = random_password.admin_setup_token.result
|
||||
},
|
||||
{
|
||||
name = "OIDC_ROLE_CLAIM"
|
||||
type = "plain_text"
|
||||
text = var.oidc_role_claim
|
||||
},
|
||||
{
|
||||
name = "OIDC_ROLE_MAP_ADMIN"
|
||||
type = "plain_text"
|
||||
text = var.oidc_role_map_admin
|
||||
},
|
||||
{
|
||||
name = "OIDC_ROLE_MAP_EDITOR"
|
||||
type = "plain_text"
|
||||
text = var.oidc_role_map_editor
|
||||
},
|
||||
],
|
||||
local.oidc_bindings,
|
||||
)
|
||||
}
|
||||
|
||||
# --- Durable Object worker (deploys first) ---
|
||||
|
||||
resource "cloudflare_worker" "sessions" {
|
||||
account_id = var.cloudflare_account_id
|
||||
name = "survey-sessions${local.resource_suffix}"
|
||||
|
||||
observability = {
|
||||
enabled = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "cloudflare_worker_version" "sessions" {
|
||||
account_id = var.cloudflare_account_id
|
||||
worker_id = cloudflare_worker.sessions.id
|
||||
compatibility_date = "2025-06-03"
|
||||
|
||||
main_module = "sessions.js"
|
||||
|
||||
modules = [{
|
||||
name = "sessions.js"
|
||||
content_file = "${var.dist_dir}/sessions.js"
|
||||
content_type = "application/javascript+module"
|
||||
}]
|
||||
|
||||
migrations = {
|
||||
old_tag = "v2"
|
||||
new_tag = "v2"
|
||||
}
|
||||
}
|
||||
|
||||
resource "cloudflare_workers_deployment" "sessions" {
|
||||
account_id = var.cloudflare_account_id
|
||||
script_name = cloudflare_worker.sessions.name
|
||||
strategy = "percentage"
|
||||
|
||||
versions = [{
|
||||
version_id = cloudflare_worker_version.sessions.id
|
||||
percentage = 100
|
||||
}]
|
||||
}
|
||||
|
||||
resource "cloudflare_worker" "api" {
|
||||
account_id = var.cloudflare_account_id
|
||||
name = "survey-api${local.resource_suffix}"
|
||||
|
||||
observability = {
|
||||
enabled = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "cloudflare_worker_version" "api" {
|
||||
account_id = var.cloudflare_account_id
|
||||
worker_id = cloudflare_worker.api.id
|
||||
compatibility_date = "2025-06-03"
|
||||
|
||||
main_module = "index.js"
|
||||
|
||||
modules = [{
|
||||
name = "index.js"
|
||||
content_file = "${var.dist_dir}/index.js"
|
||||
content_type = "application/javascript+module"
|
||||
}]
|
||||
|
||||
bindings = local.api_bindings
|
||||
|
||||
depends_on = [cloudflare_workers_deployment.sessions]
|
||||
}
|
||||
|
||||
resource "cloudflare_workers_deployment" "api" {
|
||||
account_id = var.cloudflare_account_id
|
||||
script_name = cloudflare_worker.api.name
|
||||
strategy = "percentage"
|
||||
|
||||
versions = [{
|
||||
version_id = cloudflare_worker_version.api.id
|
||||
percentage = 100
|
||||
}]
|
||||
}
|
||||
|
||||
data "cloudflare_zone" "immich_app" {
|
||||
filter = {
|
||||
account_id = var.cloudflare_account_id
|
||||
name = "immich.app"
|
||||
}
|
||||
}
|
||||
|
||||
resource "cloudflare_workers_route" "survey_api_root" {
|
||||
zone_id = data.cloudflare_zone.immich_app.zone_id
|
||||
pattern = "${module.domain.fqdn}/api"
|
||||
script = cloudflare_worker.api.name
|
||||
}
|
||||
|
||||
resource "cloudflare_workers_route" "survey_api_wildcard" {
|
||||
zone_id = data.cloudflare_zone.immich_app.zone_id
|
||||
pattern = "${module.domain.fqdn}/api/*"
|
||||
script = cloudflare_worker.api.name
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user