photos: immich accounts are configured from the ui, not the environment
IMMICH_URL/IMMICH_API_KEY lived in the platform-wide .env, which was wrong twice over: bun auto-loads .env into every process started in this directory, so `officer` itself held an immich credential it has no code to use — and connecting a library was a shell task on the server rather than something the owner could do from the app. it is a registry, not a single connection: any number of labelled accounts with one selected, the same shape headscale_servers uses. two keys against the same instance (one per immich user) is the ordinary case, so the label is what has to be unique, not the url. one active account per owner is enforced by a partial unique index rather than by convention. keys are encrypted at rest and write-only across the sidecar boundary — no route returns one, masked or otherwise. every save is validated against the live instance first, so a wrong or under-scoped key is a 400 with the reason instead of a stored row that makes every later screen fail mysteriously. the drizzle snapshot under migrations/ is regenerated; nothing applies it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -88,7 +88,9 @@ module.exports = {
|
|||||||
args: 'run src/servers/sidecar/invoiceshelf/index.ts',
|
args: 'run src/servers/sidecar/invoiceshelf/index.ts',
|
||||||
watch: false,
|
watch: false,
|
||||||
},
|
},
|
||||||
// The photo library. Wraps a self-hosted Immich and holds its API key; the platform sees none of it.
|
// The photo library. Wraps a self-hosted Immich. The instance and its key are set by the owner from
|
||||||
|
// /photos/settings and stored encrypted in `photos_config` — read here, never from the environment,
|
||||||
|
// because Bun auto-loads `.env` into every process in this directory and `officer` would hold it too.
|
||||||
{
|
{
|
||||||
name: 'officer-photos',
|
name: 'officer-photos',
|
||||||
script: 'bun',
|
script: 'bun',
|
||||||
|
|||||||
+257
-131
@@ -37,33 +37,11 @@ CREATE TABLE "users" (
|
|||||||
CONSTRAINT "users_username_unique" UNIQUE("username")
|
CONSTRAINT "users_username_unique" UNIQUE("username")
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
CREATE TABLE "dock_configs" (
|
CREATE TABLE "chat_session_events" (
|
||||||
"user_id" integer PRIMARY KEY NOT NULL,
|
"id" bigserial PRIMARY KEY NOT NULL,
|
||||||
"paths" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
"session_id" text NOT NULL,
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
"event" jsonb NOT NULL,
|
||||||
);
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "user_integrations" (
|
|
||||||
"id" serial PRIMARY KEY NOT NULL,
|
|
||||||
"user_id" integer NOT NULL,
|
|
||||||
"provider" text NOT NULL,
|
|
||||||
"server_integration_id" integer,
|
|
||||||
"config" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
CONSTRAINT "uq_user_integrations_user_provider" UNIQUE("user_id","provider")
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "user_settings" (
|
|
||||||
"user_id" integer PRIMARY KEY NOT NULL,
|
|
||||||
"settings" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "user_state" (
|
|
||||||
"user_id" integer PRIMARY KEY NOT NULL,
|
|
||||||
"state" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
CREATE TABLE "dashboard_defaults" (
|
CREATE TABLE "dashboard_defaults" (
|
||||||
@@ -89,19 +67,6 @@ CREATE TABLE "dashboards" (
|
|||||||
CONSTRAINT "uq_dashboards_user_id" UNIQUE("user_id","id")
|
CONSTRAINT "uq_dashboards_user_id" UNIQUE("user_id","id")
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
CREATE TABLE "projects" (
|
|
||||||
"id" serial PRIMARY KEY NOT NULL,
|
|
||||||
"user_id" integer NOT NULL,
|
|
||||||
"slug" text NOT NULL,
|
|
||||||
"meta" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
|
||||||
"layout" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
|
||||||
"terminals" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
|
||||||
"host_terminals" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
CONSTRAINT "uq_projects_user_slug" UNIQUE("user_id","slug")
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "screens" (
|
CREATE TABLE "screens" (
|
||||||
"id" serial PRIMARY KEY NOT NULL,
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
"user_id" integer NOT NULL,
|
"user_id" integer NOT NULL,
|
||||||
@@ -113,59 +78,6 @@ CREATE TABLE "screens" (
|
|||||||
CONSTRAINT "uq_screens_user_name" UNIQUE("user_id","name")
|
CONSTRAINT "uq_screens_user_name" UNIQUE("user_id","name")
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
CREATE TABLE "queue_jobs" (
|
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
|
||||||
"user_id" integer NOT NULL,
|
|
||||||
"lane" text NOT NULL,
|
|
||||||
"type" text NOT NULL,
|
|
||||||
"status" text DEFAULT 'queued' NOT NULL,
|
|
||||||
"current_step" integer DEFAULT 0 NOT NULL,
|
|
||||||
"steps" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
|
||||||
"meta" jsonb,
|
|
||||||
"error" text,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"started_at" timestamp with time zone,
|
|
||||||
"completed_at" timestamp with time zone
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "task_logs" (
|
|
||||||
"id" serial PRIMARY KEY NOT NULL,
|
|
||||||
"user_id" integer NOT NULL,
|
|
||||||
"task_name" text NOT NULL,
|
|
||||||
"task_dir_name" text NOT NULL,
|
|
||||||
"entry_name" text NOT NULL,
|
|
||||||
"entry_type" text NOT NULL,
|
|
||||||
"provider" text NOT NULL,
|
|
||||||
"model" text NOT NULL,
|
|
||||||
"is_error" boolean DEFAULT false NOT NULL,
|
|
||||||
"messages" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
|
||||||
"started_at" timestamp with time zone NOT NULL,
|
|
||||||
"completed_at" timestamp with time zone
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "terminal_containers" (
|
|
||||||
"user_id" integer PRIMARY KEY NOT NULL,
|
|
||||||
"docker_id" text NOT NULL,
|
|
||||||
"port" integer NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "server_config" (
|
|
||||||
"key" text PRIMARY KEY NOT NULL,
|
|
||||||
"value" jsonb NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "server_integrations" (
|
|
||||||
"id" serial PRIMARY KEY NOT NULL,
|
|
||||||
"provider" text NOT NULL,
|
|
||||||
"enabled" boolean DEFAULT true NOT NULL,
|
|
||||||
"config" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
CONSTRAINT "server_integrations_provider_unique" UNIQUE("provider")
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "email_accounts" (
|
CREATE TABLE "email_accounts" (
|
||||||
"id" serial PRIMARY KEY NOT NULL,
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
"user_id" integer NOT NULL,
|
"user_id" integer NOT NULL,
|
||||||
@@ -185,30 +97,18 @@ CREATE TABLE "email_accounts" (
|
|||||||
CONSTRAINT "uq_email_accounts_user_email" UNIQUE("user_id","email")
|
CONSTRAINT "uq_email_accounts_user_email" UNIQUE("user_id","email")
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
CREATE TABLE "pipeline_jobs" (
|
CREATE TABLE "headscale_servers" (
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
"user_id" integer NOT NULL,
|
"user_id" integer NOT NULL,
|
||||||
"task_dir_name" text NOT NULL,
|
"name" text NOT NULL,
|
||||||
"task_name" text NOT NULL,
|
"url" text NOT NULL,
|
||||||
"mode" text DEFAULT 'pipeline' NOT NULL,
|
"api_key" text NOT NULL,
|
||||||
"status" text DEFAULT 'pending' NOT NULL,
|
"version" text,
|
||||||
"inputs" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
"is_active" boolean DEFAULT false NOT NULL,
|
||||||
"cwd" text,
|
"last_seen_at" timestamp with time zone,
|
||||||
"config" jsonb NOT NULL,
|
|
||||||
"progress" jsonb,
|
|
||||||
"total_cost" jsonb,
|
|
||||||
"error" text,
|
|
||||||
"exit_code" integer,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
"started_at" timestamp with time zone,
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
"completed_at" timestamp with time zone
|
CONSTRAINT "uq_headscale_servers_user_url" UNIQUE("user_id","url")
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "chat_session_events" (
|
|
||||||
"id" bigserial PRIMARY KEY NOT NULL,
|
|
||||||
"session_id" text NOT NULL,
|
|
||||||
"event" jsonb NOT NULL,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
CREATE TABLE "music_favorites" (
|
CREATE TABLE "music_favorites" (
|
||||||
@@ -251,6 +151,173 @@ CREATE TABLE "music_playlists" (
|
|||||||
CONSTRAINT "uq_music_playlists_user_name" UNIQUE("user_id","name")
|
CONSTRAINT "uq_music_playlists_user_name" UNIQUE("user_id","name")
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "push_devices" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"token" text NOT NULL,
|
||||||
|
"platform" text NOT NULL,
|
||||||
|
"environment" text DEFAULT 'production' NOT NULL,
|
||||||
|
"bundle_id" text NOT NULL,
|
||||||
|
"app_slug" text NOT NULL,
|
||||||
|
"failure_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "uq_push_devices_token_bundle" UNIQUE("token","bundle_id"),
|
||||||
|
CONSTRAINT "ck_push_devices_platform" CHECK ("push_devices"."platform" IN ('ios', 'android')),
|
||||||
|
CONSTRAINT "ck_push_devices_environment" CHECK ("push_devices"."environment" IN ('production', 'sandbox'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "queue_jobs" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"lane" text NOT NULL,
|
||||||
|
"type" text NOT NULL,
|
||||||
|
"status" text DEFAULT 'queued' NOT NULL,
|
||||||
|
"current_step" integer DEFAULT 0 NOT NULL,
|
||||||
|
"steps" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||||
|
"meta" jsonb,
|
||||||
|
"error" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"started_at" timestamp with time zone,
|
||||||
|
"completed_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "task_logs" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"task_name" text NOT NULL,
|
||||||
|
"task_dir_name" text NOT NULL,
|
||||||
|
"entry_name" text NOT NULL,
|
||||||
|
"entry_type" text NOT NULL,
|
||||||
|
"provider" text NOT NULL,
|
||||||
|
"model" text NOT NULL,
|
||||||
|
"is_error" boolean DEFAULT false NOT NULL,
|
||||||
|
"messages" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||||
|
"started_at" timestamp with time zone NOT NULL,
|
||||||
|
"completed_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "terminal_containers" (
|
||||||
|
"user_id" integer PRIMARY KEY NOT NULL,
|
||||||
|
"docker_id" text NOT NULL,
|
||||||
|
"port" integer NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "photos_config" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"url" text NOT NULL,
|
||||||
|
"api_key" text NOT NULL,
|
||||||
|
"version" text,
|
||||||
|
"last_seen_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "pipeline_jobs" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"task_dir_name" text NOT NULL,
|
||||||
|
"task_name" text NOT NULL,
|
||||||
|
"mode" text DEFAULT 'pipeline' NOT NULL,
|
||||||
|
"status" text DEFAULT 'pending' NOT NULL,
|
||||||
|
"inputs" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"cwd" text,
|
||||||
|
"config" jsonb NOT NULL,
|
||||||
|
"progress" jsonb,
|
||||||
|
"total_cost" jsonb,
|
||||||
|
"error" text,
|
||||||
|
"exit_code" integer,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"started_at" timestamp with time zone,
|
||||||
|
"completed_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "server_config" (
|
||||||
|
"key" text PRIMARY KEY NOT NULL,
|
||||||
|
"value" jsonb NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "server_integrations" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"provider" text NOT NULL,
|
||||||
|
"enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"config" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "server_integrations_provider_unique" UNIQUE("provider"),
|
||||||
|
CONSTRAINT "ck_server_integrations_provider" CHECK ("server_integrations"."provider" IN ('google', 'apify'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "soulseek_browse_dirs" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"snapshot_id" integer NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"parent_path" text,
|
||||||
|
"depth" integer DEFAULT 1 NOT NULL,
|
||||||
|
"label" text DEFAULT '' NOT NULL,
|
||||||
|
"child_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
"file_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
"total_size" bigint DEFAULT 0 NOT NULL,
|
||||||
|
"subtree_file_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
"subtree_size" bigint DEFAULT 0 NOT NULL,
|
||||||
|
"files" jsonb DEFAULT '[]'::jsonb NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "soulseek_browse_snapshots" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"username" text NOT NULL,
|
||||||
|
"status" text DEFAULT 'pending' NOT NULL,
|
||||||
|
"error" text,
|
||||||
|
"directory_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
"file_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
"total_size" bigint DEFAULT 0 NOT NULL,
|
||||||
|
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"completed_at" timestamp with time zone,
|
||||||
|
CONSTRAINT "uq_soulseek_browse_snapshots_user_username" UNIQUE("user_id","username")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "soulseek_favorites" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"username" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "uq_soulseek_favorites_user_username" UNIQUE("user_id","username")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "dock_configs" (
|
||||||
|
"user_id" integer PRIMARY KEY NOT NULL,
|
||||||
|
"paths" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "user_integrations" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"provider" text NOT NULL,
|
||||||
|
"server_integration_id" integer,
|
||||||
|
"config" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "uq_user_integrations_user_provider" UNIQUE("user_id","provider"),
|
||||||
|
CONSTRAINT "ck_user_integrations_provider" CHECK ("user_integrations"."provider" IN ('google', 'browser-relay'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "user_settings" (
|
||||||
|
"user_id" integer PRIMARY KEY NOT NULL,
|
||||||
|
"settings" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "user_state" (
|
||||||
|
"user_id" integer PRIMARY KEY NOT NULL,
|
||||||
|
"state" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
CREATE TABLE "vault_tokens" (
|
CREATE TABLE "vault_tokens" (
|
||||||
"user_id" integer PRIMARY KEY NOT NULL,
|
"user_id" integer PRIMARY KEY NOT NULL,
|
||||||
"access_token" text NOT NULL,
|
"access_token" text NOT NULL,
|
||||||
@@ -267,34 +334,93 @@ CREATE TABLE "vault_unlock_keys" (
|
|||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "wallet_chain_cache" (
|
||||||
|
"wallet_id" integer PRIMARY KEY NOT NULL,
|
||||||
|
"snapshot" jsonb,
|
||||||
|
"synced_at" timestamp with time zone,
|
||||||
|
"last_error" text,
|
||||||
|
"last_error_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "wallet_frozen_utxos" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"wallet_id" integer NOT NULL,
|
||||||
|
"outpoint" text NOT NULL,
|
||||||
|
"reason" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "uq_wallet_frozen_utxos_wallet_outpoint" UNIQUE("wallet_id","outpoint")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "wallet_labels" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"wallet_id" integer NOT NULL,
|
||||||
|
"kind" text NOT NULL,
|
||||||
|
"ref" text NOT NULL,
|
||||||
|
"label" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "uq_wallet_labels_wallet_kind_ref" UNIQUE("wallet_id","kind","ref")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "wallet_wallets" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"kind" text NOT NULL,
|
||||||
|
"network" text DEFAULT 'bitcoin' NOT NULL,
|
||||||
|
"config" text,
|
||||||
|
"seed_envelope" text,
|
||||||
|
"fingerprint" text,
|
||||||
|
"xpubs" jsonb,
|
||||||
|
"default_bip" integer DEFAULT 84 NOT NULL,
|
||||||
|
"is_active" boolean DEFAULT false NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "uq_wallet_wallets_user_name" UNIQUE("user_id","name")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
ALTER TABLE "passkey_challenges" ADD CONSTRAINT "passkey_challenges_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
ALTER TABLE "passkey_challenges" ADD CONSTRAINT "passkey_challenges_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
ALTER TABLE "passkeys" ADD CONSTRAINT "passkeys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
ALTER TABLE "passkeys" ADD CONSTRAINT "passkeys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "dashboard_defaults" ADD CONSTRAINT "dashboard_defaults_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "dashboards" ADD CONSTRAINT "dashboards_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "screens" ADD CONSTRAINT "screens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "email_accounts" ADD CONSTRAINT "email_accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "headscale_servers" ADD CONSTRAINT "headscale_servers_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "music_favorites" ADD CONSTRAINT "music_favorites_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "music_now_playing" ADD CONSTRAINT "music_now_playing_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "music_playlist_items" ADD CONSTRAINT "music_playlist_items_playlist_id_music_playlists_id_fk" FOREIGN KEY ("playlist_id") REFERENCES "public"."music_playlists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "music_playlists" ADD CONSTRAINT "music_playlists_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "push_devices" ADD CONSTRAINT "push_devices_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "queue_jobs" ADD CONSTRAINT "queue_jobs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "task_logs" ADD CONSTRAINT "task_logs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "terminal_containers" ADD CONSTRAINT "terminal_containers_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "photos_config" ADD CONSTRAINT "photos_config_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "pipeline_jobs" ADD CONSTRAINT "pipeline_jobs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "soulseek_browse_dirs" ADD CONSTRAINT "soulseek_browse_dirs_snapshot_id_fk" FOREIGN KEY ("snapshot_id") REFERENCES "public"."soulseek_browse_snapshots"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "soulseek_browse_snapshots" ADD CONSTRAINT "soulseek_browse_snapshots_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "soulseek_favorites" ADD CONSTRAINT "soulseek_favorites_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
ALTER TABLE "dock_configs" ADD CONSTRAINT "dock_configs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
ALTER TABLE "dock_configs" ADD CONSTRAINT "dock_configs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
ALTER TABLE "user_integrations" ADD CONSTRAINT "user_integrations_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
ALTER TABLE "user_integrations" ADD CONSTRAINT "user_integrations_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
ALTER TABLE "user_integrations" ADD CONSTRAINT "user_integrations_server_integration_id_server_integrations_id_fk" FOREIGN KEY ("server_integration_id") REFERENCES "public"."server_integrations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
ALTER TABLE "user_integrations" ADD CONSTRAINT "user_integrations_server_integration_id_server_integrations_id_fk" FOREIGN KEY ("server_integration_id") REFERENCES "public"."server_integrations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
ALTER TABLE "user_settings" ADD CONSTRAINT "user_settings_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
ALTER TABLE "user_settings" ADD CONSTRAINT "user_settings_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
ALTER TABLE "user_state" ADD CONSTRAINT "user_state_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
ALTER TABLE "user_state" ADD CONSTRAINT "user_state_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
ALTER TABLE "dashboard_defaults" ADD CONSTRAINT "dashboard_defaults_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "dashboards" ADD CONSTRAINT "dashboards_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "projects" ADD CONSTRAINT "projects_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "screens" ADD CONSTRAINT "screens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "queue_jobs" ADD CONSTRAINT "queue_jobs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "task_logs" ADD CONSTRAINT "task_logs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "terminal_containers" ADD CONSTRAINT "terminal_containers_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "email_accounts" ADD CONSTRAINT "email_accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "pipeline_jobs" ADD CONSTRAINT "pipeline_jobs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "music_favorites" ADD CONSTRAINT "music_favorites_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "music_now_playing" ADD CONSTRAINT "music_now_playing_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "music_playlist_items" ADD CONSTRAINT "music_playlist_items_playlist_id_music_playlists_id_fk" FOREIGN KEY ("playlist_id") REFERENCES "public"."music_playlists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "music_playlists" ADD CONSTRAINT "music_playlists_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "vault_tokens" ADD CONSTRAINT "vault_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
ALTER TABLE "vault_tokens" ADD CONSTRAINT "vault_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
ALTER TABLE "vault_unlock_keys" ADD CONSTRAINT "vault_unlock_keys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
ALTER TABLE "vault_unlock_keys" ADD CONSTRAINT "vault_unlock_keys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "wallet_chain_cache" ADD CONSTRAINT "wallet_chain_cache_wallet_id_wallet_wallets_id_fk" FOREIGN KEY ("wallet_id") REFERENCES "public"."wallet_wallets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "wallet_frozen_utxos" ADD CONSTRAINT "wallet_frozen_utxos_wallet_id_wallet_wallets_id_fk" FOREIGN KEY ("wallet_id") REFERENCES "public"."wallet_wallets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "wallet_labels" ADD CONSTRAINT "wallet_labels_wallet_id_wallet_wallets_id_fk" FOREIGN KEY ("wallet_id") REFERENCES "public"."wallet_wallets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "wallet_wallets" ADD CONSTRAINT "wallet_wallets_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
CREATE INDEX "idx_token_blacklist_expires" ON "token_blacklist" USING btree ("expires_at");--> statement-breakpoint
|
CREATE INDEX "idx_token_blacklist_expires" ON "token_blacklist" USING btree ("expires_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_chat_session_events_session_id" ON "chat_session_events" USING btree ("session_id","id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "uq_headscale_servers_one_active" ON "headscale_servers" USING btree ("user_id") WHERE "headscale_servers"."is_active";--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_music_favorites_user_kind" ON "music_favorites" USING btree ("user_id","kind");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_music_playlist_items_playlist" ON "music_playlist_items" USING btree ("playlist_id","position");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_push_devices_user" ON "push_devices" USING btree ("user_id");--> statement-breakpoint
|
||||||
CREATE INDEX "idx_queue_jobs_status_lane" ON "queue_jobs" USING btree ("status","lane");--> statement-breakpoint
|
CREATE INDEX "idx_queue_jobs_status_lane" ON "queue_jobs" USING btree ("status","lane");--> statement-breakpoint
|
||||||
CREATE INDEX "idx_queue_jobs_user" ON "queue_jobs" USING btree ("user_id");--> statement-breakpoint
|
CREATE INDEX "idx_queue_jobs_user" ON "queue_jobs" USING btree ("user_id");--> statement-breakpoint
|
||||||
CREATE INDEX "idx_task_logs_user_started" ON "task_logs" USING btree ("user_id","started_at");--> statement-breakpoint
|
CREATE INDEX "idx_task_logs_user_started" ON "task_logs" USING btree ("user_id","started_at");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "photos_config_user_idx" ON "photos_config" USING btree ("user_id");--> statement-breakpoint
|
||||||
CREATE INDEX "idx_pipeline_jobs_user_created" ON "pipeline_jobs" USING btree ("user_id","created_at");--> statement-breakpoint
|
CREATE INDEX "idx_pipeline_jobs_user_created" ON "pipeline_jobs" USING btree ("user_id","created_at");--> statement-breakpoint
|
||||||
CREATE INDEX "idx_pipeline_jobs_status" ON "pipeline_jobs" USING btree ("status");--> statement-breakpoint
|
CREATE INDEX "idx_pipeline_jobs_status" ON "pipeline_jobs" USING btree ("status");--> statement-breakpoint
|
||||||
CREATE INDEX "idx_chat_session_events_session_id" ON "chat_session_events" USING btree ("session_id","id");--> statement-breakpoint
|
CREATE INDEX "idx_soulseek_browse_dirs_snapshot_name" ON "soulseek_browse_dirs" USING btree ("snapshot_id","name");--> statement-breakpoint
|
||||||
CREATE INDEX "idx_music_favorites_user_kind" ON "music_favorites" USING btree ("user_id","kind");--> statement-breakpoint
|
CREATE INDEX "idx_soulseek_browse_dirs_snapshot_parent" ON "soulseek_browse_dirs" USING btree ("snapshot_id","parent_path","name");--> statement-breakpoint
|
||||||
CREATE INDEX "idx_music_playlist_items_playlist" ON "music_playlist_items" USING btree ("playlist_id","position");
|
CREATE UNIQUE INDEX "uq_wallet_wallets_one_active" ON "wallet_wallets" USING btree ("user_id") WHERE "wallet_wallets"."is_active";
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,8 @@
|
|||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1785338790645,
|
"when": 1785771021535,
|
||||||
"tag": "0000_crazy_elektra",
|
"tag": "0000_new_princess_powerful",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -141,6 +141,17 @@ export {
|
|||||||
recordHeadscaleProbe,
|
recordHeadscaleProbe,
|
||||||
} from './queries/headscale';
|
} from './queries/headscale';
|
||||||
export type { HeadscaleServer, HeadscaleServerCredentials } from './queries/headscale';
|
export type { HeadscaleServer, HeadscaleServerCredentials } from './queries/headscale';
|
||||||
|
export {
|
||||||
|
listPhotosAccounts,
|
||||||
|
getActivePhotosCredentials,
|
||||||
|
getPhotosCredentials,
|
||||||
|
createPhotosAccount,
|
||||||
|
updatePhotosAccount,
|
||||||
|
setActivePhotosAccount,
|
||||||
|
deletePhotosAccount,
|
||||||
|
recordPhotosProbe,
|
||||||
|
} from './queries/photos';
|
||||||
|
export type { PhotosAccount, PhotosCredentials } from './queries/photos';
|
||||||
export {
|
export {
|
||||||
getVaultTokens,
|
getVaultTokens,
|
||||||
setVaultTokens,
|
setVaultTokens,
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { eq, and, desc } from 'drizzle-orm';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { photosConfig } from '../schema';
|
||||||
|
import { encryptSecret, decryptSecret } from '../crypto';
|
||||||
|
|
||||||
|
// Immich account registry for the officer-photos sidecar. Callers deal in PLAINTEXT — encryption to and from
|
||||||
|
// at-rest ciphertext happens here. See ../crypto.ts and ../schema/photos.ts.
|
||||||
|
//
|
||||||
|
// Two return types, and the split is the safety property:
|
||||||
|
// PhotosAccount — safe to serialize to the browser. Has NO api key field at all, not even a masked one.
|
||||||
|
// PhotosCredentials — url + decrypted key, for the sidecar's own upstream calls. Never returned by a route.
|
||||||
|
// `accountCols` is what enforces it: a bare `select()` would put the ciphertext column into every list
|
||||||
|
// response the moment someone forgot to strip it.
|
||||||
|
|
||||||
|
export type PhotosAccount = {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
version: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
lastSeenAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PhotosCredentials = { id: number; label: string; url: string; apiKey: string };
|
||||||
|
|
||||||
|
const accountCols = {
|
||||||
|
id: photosConfig.id,
|
||||||
|
label: photosConfig.label,
|
||||||
|
url: photosConfig.url,
|
||||||
|
version: photosConfig.version,
|
||||||
|
isActive: photosConfig.isActive,
|
||||||
|
lastSeenAt: photosConfig.lastSeenAt,
|
||||||
|
createdAt: photosConfig.createdAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Every account the owner has added, active first then newest. Never includes the API key. */
|
||||||
|
export async function listPhotosAccounts(userId: number): Promise<PhotosAccount[]> {
|
||||||
|
return db
|
||||||
|
.select(accountCols)
|
||||||
|
.from(photosConfig)
|
||||||
|
.where(eq(photosConfig.userId, userId))
|
||||||
|
.orderBy(desc(photosConfig.isActive), desc(photosConfig.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The selected account with its key decrypted, or null when none is added. */
|
||||||
|
export async function getActivePhotosCredentials(userId: number): Promise<PhotosCredentials | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(photosConfig)
|
||||||
|
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.isActive, true)));
|
||||||
|
if (!row) return null;
|
||||||
|
return { id: row.id, label: row.label, url: row.url, apiKey: decryptSecret(row.apiKey) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One account's credentials by id — for probing a specific account rather than the active one. */
|
||||||
|
export async function getPhotosCredentials(userId: number, id: number): Promise<PhotosCredentials | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(photosConfig)
|
||||||
|
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.id, id)));
|
||||||
|
if (!row) return null;
|
||||||
|
return { id: row.id, label: row.label, url: row.url, apiKey: decryptSecret(row.apiKey) };
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreatePhotosAccountParams = {
|
||||||
|
userId: number;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
apiKey: string;
|
||||||
|
version: string | null;
|
||||||
|
/** Select it. True for the first account, so the UI is never left with accounts added but none chosen. */
|
||||||
|
activate: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Add an account. The key is encrypted before write; the returned row carries no key. */
|
||||||
|
export async function createPhotosAccount(params: CreatePhotosAccountParams): Promise<PhotosAccount> {
|
||||||
|
const { userId, label, url, apiKey, version, activate } = params;
|
||||||
|
return db.transaction(async (tx) => {
|
||||||
|
if (activate) {
|
||||||
|
await tx
|
||||||
|
.update(photosConfig)
|
||||||
|
.set({ isActive: false, updatedAt: new Date() })
|
||||||
|
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.isActive, true)));
|
||||||
|
}
|
||||||
|
const [row] = await tx
|
||||||
|
.insert(photosConfig)
|
||||||
|
.values({
|
||||||
|
userId,
|
||||||
|
label,
|
||||||
|
url,
|
||||||
|
apiKey: encryptSecret(apiKey),
|
||||||
|
version,
|
||||||
|
isActive: activate,
|
||||||
|
lastSeenAt: version ? new Date() : null,
|
||||||
|
})
|
||||||
|
.returning(accountCols);
|
||||||
|
return row!;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdatePhotosAccountParams = { label?: string; url?: string; apiKey?: string; version?: string | null };
|
||||||
|
|
||||||
|
/** Edit an account. Omitted fields are left alone; a supplied key is re-encrypted. */
|
||||||
|
export async function updatePhotosAccount(
|
||||||
|
userId: number,
|
||||||
|
id: number,
|
||||||
|
params: UpdatePhotosAccountParams,
|
||||||
|
): Promise<PhotosAccount | null> {
|
||||||
|
const set: Record<string, unknown> = { updatedAt: new Date() };
|
||||||
|
if (params.label !== undefined) set.label = params.label;
|
||||||
|
if (params.url !== undefined) set.url = params.url;
|
||||||
|
if (params.apiKey !== undefined) set.apiKey = encryptSecret(params.apiKey);
|
||||||
|
if (params.version !== undefined) set.version = params.version;
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.update(photosConfig)
|
||||||
|
.set(set)
|
||||||
|
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.id, id)))
|
||||||
|
.returning(accountCols);
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Switch accounts. Clearing the others first keeps the one-active partial index satisfied. */
|
||||||
|
export async function setActivePhotosAccount(userId: number, id: number): Promise<PhotosAccount | null> {
|
||||||
|
return db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.update(photosConfig)
|
||||||
|
.set({ isActive: false, updatedAt: new Date() })
|
||||||
|
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.isActive, true)));
|
||||||
|
const [row] = await tx
|
||||||
|
.update(photosConfig)
|
||||||
|
.set({ isActive: true, updatedAt: new Date() })
|
||||||
|
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.id, id)))
|
||||||
|
.returning(accountCols);
|
||||||
|
return row ?? null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove an account. If it was the active one the newest survivor is promoted — otherwise removing the
|
||||||
|
* account in use would leave the owner with accounts added but none selected, which reads as "not connected"
|
||||||
|
* and is a confusing place to land.
|
||||||
|
*/
|
||||||
|
export async function deletePhotosAccount(userId: number, id: number): Promise<boolean> {
|
||||||
|
return db.transaction(async (tx) => {
|
||||||
|
const [deleted] = await tx
|
||||||
|
.delete(photosConfig)
|
||||||
|
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.id, id)))
|
||||||
|
.returning({ id: photosConfig.id, wasActive: photosConfig.isActive });
|
||||||
|
if (!deleted) return false;
|
||||||
|
|
||||||
|
if (deleted.wasActive) {
|
||||||
|
const [next] = await tx
|
||||||
|
.select({ id: photosConfig.id })
|
||||||
|
.from(photosConfig)
|
||||||
|
.where(eq(photosConfig.userId, userId))
|
||||||
|
.orderBy(desc(photosConfig.createdAt))
|
||||||
|
.limit(1);
|
||||||
|
if (next) {
|
||||||
|
await tx.update(photosConfig).set({ isActive: true, updatedAt: new Date() }).where(eq(photosConfig.id, next.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stamp a successful probe, so the UI can tell "never reached" from "was reachable, now isn't". */
|
||||||
|
export async function recordPhotosProbe(userId: number, id: number, version: string | null): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(photosConfig)
|
||||||
|
.set({ version, lastSeenAt: new Date() })
|
||||||
|
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.id, id)));
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ export * from './headscale';
|
|||||||
export * from './music';
|
export * from './music';
|
||||||
export * from './notify';
|
export * from './notify';
|
||||||
export * from './operations';
|
export * from './operations';
|
||||||
|
export * from './photos';
|
||||||
export * from './pipeline-jobs';
|
export * from './pipeline-jobs';
|
||||||
export * from './server';
|
export * from './server';
|
||||||
export * from './soulseek';
|
export * from './soulseek';
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { pgTable, serial, integer, text, boolean, timestamp, unique, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { users } from './auth';
|
||||||
|
|
||||||
|
// The Immich accounts behind /photos, for the officer-photos sidecar.
|
||||||
|
//
|
||||||
|
// This used to be IMMICH_URL + IMMICH_API_KEY in the platform-wide `.env`, which was wrong twice over: Bun
|
||||||
|
// auto-loads `.env` into EVERY process started in the platform directory, so `officer` itself held an Immich
|
||||||
|
// credential it has no code to use — and connecting a photo library was a shell task on the server rather
|
||||||
|
// than something the owner could do from the app.
|
||||||
|
//
|
||||||
|
// It is a REGISTRY, not a single row: the owner adds any number of accounts and switches between them, the
|
||||||
|
// same shape headscale_servers uses. Two accounts on the same instance is the normal case (one key per
|
||||||
|
// Immich user), which is why the uniqueness below is on the label and not on the URL.
|
||||||
|
//
|
||||||
|
// `api_key` is encrypted at rest via ../crypto.ts. An Immich key can read and delete the entire library, so a
|
||||||
|
// DB dump must not hand it over. Encryption is confined to queries/photos.ts; nothing outside that file sees
|
||||||
|
// ciphertext, and no route ever returns the key at all.
|
||||||
|
export const photosConfig = pgTable(
|
||||||
|
'photos_config',
|
||||||
|
{
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
userId: integer('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
/** What the owner calls this account. The switcher shows nothing else, so it has to be theirs to set. */
|
||||||
|
label: text('label').notNull(),
|
||||||
|
// Normalized without a trailing slash before write, so `${url}/api/...` never doubles the separator.
|
||||||
|
url: text('url').notNull(),
|
||||||
|
apiKey: text('api_key').notNull(), // encrypted
|
||||||
|
/** Immich version seen at the last successful probe — shown in the UI, never used for behaviour. */
|
||||||
|
version: text('version'),
|
||||||
|
isActive: boolean('is_active').notNull().default(false),
|
||||||
|
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
// Labels are how the owner tells two accounts apart — duplicates would make the switcher useless. Not
|
||||||
|
// unique on url: several keys against one instance is the whole point.
|
||||||
|
unique('uq_photos_config_user_label').on(t.userId, t.label),
|
||||||
|
// At most one active account per owner, enforced by the DB rather than by convention: a partial unique
|
||||||
|
// index over the active rows only. setActivePhotosAccount still clears the others in a transaction, but a
|
||||||
|
// bug there fails loudly here instead of silently leaving two accounts active and the UI picking one.
|
||||||
|
uniqueIndex('uq_photos_config_one_active')
|
||||||
|
.on(t.userId)
|
||||||
|
.where(sql`${t.isActive}`),
|
||||||
|
],
|
||||||
|
);
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import type { UpstreamTarget } from './upstream';
|
||||||
|
import {
|
||||||
|
createPhotosAccount,
|
||||||
|
deletePhotosAccount,
|
||||||
|
getPhotosCredentials,
|
||||||
|
listPhotosAccounts,
|
||||||
|
recordPhotosProbe,
|
||||||
|
setActivePhotosAccount,
|
||||||
|
updatePhotosAccount,
|
||||||
|
} from 'officerdb';
|
||||||
|
import { callUpstream, invalidateConfig, normalizeBase } from './upstream';
|
||||||
|
|
||||||
|
// `/_config` — the Immich account registry, driven from the app rather than from a shell on the server.
|
||||||
|
//
|
||||||
|
// The API key is WRITE-ONLY across this boundary. The list reports each account's label, URL and whether it
|
||||||
|
// is selected; it has no field that could carry a key, masked or otherwise. The only way to change one is to
|
||||||
|
// send a new one, which is the same shape headscale's server registry uses.
|
||||||
|
//
|
||||||
|
// A save is validated before it is stored: an Immich key that is missing, wrong or under-scoped is a 400
|
||||||
|
// with the reason, not a saved row that makes every later screen fail mysteriously. That matters more here
|
||||||
|
// than usual — Immich keys are SCOPED, and a key created without the right permissions returns 403 on
|
||||||
|
// individual routes while looking perfectly valid on the ones it does cover.
|
||||||
|
//
|
||||||
|
// Adding, editing, switching and removing all invalidate the upstream cache. Forgetting one would leave the
|
||||||
|
// sidecar serving the previous account's photos for up to a minute, which reads as a caching bug in the grid
|
||||||
|
// rather than as what it is.
|
||||||
|
|
||||||
|
export type ProbeResult =
|
||||||
|
| { ok: true; version: string | null; user: string | null }
|
||||||
|
| { ok: false; version: string | null; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask an instance whether it is really there and whether the key works.
|
||||||
|
*
|
||||||
|
* Two calls, because they answer different questions: `/server/version` is unauthenticated, so a failure
|
||||||
|
* there means the URL is wrong or Immich is down, while `/users/me` failing after it succeeded means the
|
||||||
|
* key is the problem. Collapsing them would report "instance unreachable" for a mistyped key.
|
||||||
|
*/
|
||||||
|
export async function probe(cfg: UpstreamTarget): Promise<ProbeResult> {
|
||||||
|
let version: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const versionRes = await callUpstream(cfg, { path: '/api/server/version', withKey: false });
|
||||||
|
if (!versionRes.ok) return { ok: false, version: null, error: `instance returned ${versionRes.status}` };
|
||||||
|
|
||||||
|
const v = (await versionRes.json()) as { major?: number; minor?: number; patch?: number };
|
||||||
|
if ([v.major, v.minor, v.patch].every((n) => typeof n === 'number')) {
|
||||||
|
version = `${v.major}.${v.minor}.${v.patch}`;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, version: null, error: `could not reach the instance (${String(err)})` };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const meRes = await callUpstream(cfg, { path: '/api/users/me' });
|
||||||
|
if (!meRes.ok) {
|
||||||
|
const reason = meRes.status === 403 ? 'API key is under-scoped for this instance' : 'API key was rejected';
|
||||||
|
return { ok: false, version, error: `${reason} (${meRes.status})` };
|
||||||
|
}
|
||||||
|
const me = (await meRes.json()) as { email?: string; name?: string };
|
||||||
|
return { ok: true, version, user: me.email ?? me.name ?? null };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, version, error: String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the browser is allowed to know about the registry. Never includes a key. */
|
||||||
|
async function accountList(userId: number): Promise<Response> {
|
||||||
|
const accounts = await listPhotosAccounts(userId);
|
||||||
|
const active = accounts.find((account) => account.isActive) ?? null;
|
||||||
|
return Response.json({ configured: !!active, activeId: active?.id ?? null, accounts });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record that the instance answered, so the UI can tell "never connected" from "was working, now isn't". */
|
||||||
|
export async function noteProbe(userId: number, id: number, version: string | null): Promise<void> {
|
||||||
|
await recordPhotosProbe(userId, id, version).catch(() => {
|
||||||
|
/* a stale lastSeenAt is not worth failing a request over */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const bad = (error: string, status = 400) => Response.json({ error }, { status });
|
||||||
|
|
||||||
|
type AccountBody = { label?: unknown; url?: unknown; apiKey?: unknown };
|
||||||
|
|
||||||
|
const readBody = async (req: Request): Promise<AccountBody> =>
|
||||||
|
((await req.json().catch(() => null)) as AccountBody | null) ?? {};
|
||||||
|
|
||||||
|
const readLabel = (body: AccountBody): string => (typeof body.label === 'string' ? body.label.trim() : '');
|
||||||
|
const readUrl = (body: AccountBody): string => (typeof body.url === 'string' ? normalizeBase(body.url) : '');
|
||||||
|
const readKey = (body: AccountBody): string => (typeof body.apiKey === 'string' ? body.apiKey.trim() : '');
|
||||||
|
|
||||||
|
const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url);
|
||||||
|
|
||||||
|
const duplicateLabel = (err: unknown): boolean => String(err).includes('uq_photos_config_user_label');
|
||||||
|
|
||||||
|
/** Add an account: validated against the live instance, then stored encrypted. */
|
||||||
|
async function addAccount(req: Request, userId: number): Promise<Response> {
|
||||||
|
const body = await readBody(req);
|
||||||
|
const url = readUrl(body);
|
||||||
|
const apiKey = readKey(body);
|
||||||
|
let label = readLabel(body);
|
||||||
|
|
||||||
|
if (!url || !apiKey) return bad('url and apiKey are required');
|
||||||
|
if (!isHttpUrl(url)) return bad('url must start with http:// or https://');
|
||||||
|
|
||||||
|
const result = await probe({ base: url, key: apiKey });
|
||||||
|
if (!result.ok) return Response.json({ error: result.error, version: result.version }, { status: 400 });
|
||||||
|
|
||||||
|
// An unlabelled account takes the name Immich itself knows it by, which is nearly always what the owner
|
||||||
|
// would have typed. Falling back to the host keeps the switcher readable even for an anonymous key.
|
||||||
|
if (!label) label = result.user ?? new URL(url).host;
|
||||||
|
|
||||||
|
// The first account wins the selection: a registry with rows but nothing selected reads as "not connected".
|
||||||
|
const existing = await listPhotosAccounts(userId);
|
||||||
|
const activate = existing.length === 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const account = await createPhotosAccount({ userId, label, url, apiKey, version: result.version, activate });
|
||||||
|
invalidateConfig(userId);
|
||||||
|
return Response.json({ account, user: result.user });
|
||||||
|
} catch (err) {
|
||||||
|
if (duplicateLabel(err)) return bad(`you already have an account called "${label}"`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Edit one account. A blank key means "keep the stored one", so a rename does not need the key re-typed. */
|
||||||
|
async function editAccount(req: Request, userId: number, id: number): Promise<Response> {
|
||||||
|
const body = await readBody(req);
|
||||||
|
const label = readLabel(body);
|
||||||
|
const url = readUrl(body);
|
||||||
|
const apiKey = readKey(body);
|
||||||
|
|
||||||
|
const current = await getPhotosCredentials(userId, id);
|
||||||
|
if (!current) return bad('no such account', 404);
|
||||||
|
if (url && !isHttpUrl(url)) return bad('url must start with http:// or https://');
|
||||||
|
|
||||||
|
// Re-validate whenever what we would talk to changes. A rename on its own never touches the instance.
|
||||||
|
let version: string | null | undefined;
|
||||||
|
if ((url && url !== current.url) || apiKey) {
|
||||||
|
const result = await probe({ base: url || current.url, key: apiKey || current.apiKey });
|
||||||
|
if (!result.ok) return Response.json({ error: result.error, version: result.version }, { status: 400 });
|
||||||
|
version = result.version;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const account = await updatePhotosAccount(userId, id, {
|
||||||
|
label: label || undefined,
|
||||||
|
url: url || undefined,
|
||||||
|
apiKey: apiKey || undefined,
|
||||||
|
version,
|
||||||
|
});
|
||||||
|
if (!account) return bad('no such account', 404);
|
||||||
|
invalidateConfig(userId);
|
||||||
|
return Response.json({ account });
|
||||||
|
} catch (err) {
|
||||||
|
if (duplicateLabel(err)) return bad(`you already have an account called "${label}"`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `subpath` is '' for /_config, or '/<id>' / '/<id>/activate'. */
|
||||||
|
export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise<Response> {
|
||||||
|
const [, rawId, action] = subpath.split('/');
|
||||||
|
|
||||||
|
if (!rawId) {
|
||||||
|
if (req.method === 'GET') return accountList(userId);
|
||||||
|
if (req.method === 'POST' || req.method === 'PUT') return addAccount(req, userId);
|
||||||
|
return bad('method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = Number(rawId);
|
||||||
|
if (!Number.isInteger(id) || id <= 0) return bad('invalid account id', 404);
|
||||||
|
|
||||||
|
if (action === 'activate') {
|
||||||
|
if (req.method !== 'POST') return bad('method not allowed', 405);
|
||||||
|
const account = await setActivePhotosAccount(userId, id);
|
||||||
|
if (!account) return bad('no such account', 404);
|
||||||
|
invalidateConfig(userId);
|
||||||
|
return accountList(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action) return bad('not found', 404);
|
||||||
|
|
||||||
|
if (req.method === 'PATCH' || req.method === 'PUT') return editAccount(req, userId, id);
|
||||||
|
|
||||||
|
if (req.method === 'DELETE') {
|
||||||
|
const removed = await deletePhotosAccount(userId, id);
|
||||||
|
if (!removed) return bad('no such account', 404);
|
||||||
|
invalidateConfig(userId);
|
||||||
|
return accountList(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bad('method not allowed', 405);
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||||
import { createSidecarConnector } from '../connect';
|
import { createSidecarConnector } from '../connect';
|
||||||
|
import { handleConfigRoute, noteProbe, probe } from './config';
|
||||||
import { handleOfficerRoute } from './routes';
|
import { handleOfficerRoute } from './routes';
|
||||||
import { callUpstream, getBase, getConfig } from './upstream';
|
import { getConfig } from './upstream';
|
||||||
|
|
||||||
// The officer-photos sidecar. Owns the whole Immich contract for Officer: the instance URL and the API key.
|
// The officer-photos sidecar. Owns the whole Immich contract for Officer: the instance URL and the API key.
|
||||||
// The platform API is a thin auth-gated forwarder (src/servers/api/photos/router.ts) holding no Immich
|
// The platform API is a thin auth-gated forwarder (src/servers/api/photos/router.ts) holding no Immich
|
||||||
// credentials.
|
// credentials.
|
||||||
//
|
//
|
||||||
|
// The connection is the OWNER'S to set, from the UI — it is stored encrypted in `photos_config` and no
|
||||||
|
// longer read from the environment. See upstream.ts for why that move mattered.
|
||||||
|
//
|
||||||
// Named `photos`, not `immich`: the feature is the owner's photo library, and Immich is the implementation
|
// Named `photos`, not `immich`: the feature is the owner's photo library, and Immich is the implementation
|
||||||
// behind it. The route surface below is Officer's, so swapping the backend would not move the mount point.
|
// behind it. The route surface below is Officer's, so swapping the backend would not move the mount point.
|
||||||
//
|
//
|
||||||
@@ -15,9 +19,18 @@ import { callUpstream, getBase, getConfig } from './upstream';
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
// HTTP CONTRACT — the platform strips its /api/photos mount prefix before forwarding.
|
// HTTP CONTRACT — the platform strips its /api/photos mount prefix before forwarding.
|
||||||
//
|
//
|
||||||
// GET /_health ours. Confirms the key is live and reports the Immich version and who the key is.
|
// GET /_health ours. Confirms the key is live; reports the Immich version and who the key is.
|
||||||
// * /_officer/<path> forwarded to <IMMICH_URL>/api/<path>, first-segment allow-list (routes.ts)
|
// GET /_config the account registry MINUS every key: { configured, activeId, accounts[] }
|
||||||
// anything else 404
|
// POST /_config { label?, url, apiKey } — validated against the instance, then stored encrypted
|
||||||
|
// PATCH /_config/:id { label?, url?, apiKey? } — a blank key keeps the stored one
|
||||||
|
// POST /_config/:id/activate switch to that account
|
||||||
|
// DEL /_config/:id remove it; the newest survivor is promoted if it was the active one
|
||||||
|
// * /_officer/<path> forwarded to <active url>/api/<path>, first-segment allow-list (routes.ts)
|
||||||
|
// anything else 404
|
||||||
|
//
|
||||||
|
// Every route needs `X-Officer-User`, which the platform proxy sets after authenticating the owner. We bind
|
||||||
|
// loopback only, so its presence is the trust signal — a request without it did not come through the
|
||||||
|
// platform, and the connection is per-owner data.
|
||||||
//
|
//
|
||||||
// So `/api/photos/_officer/albums` on the platform is `/api/albums` on Immich, and
|
// So `/api/photos/_officer/albums` on the platform is `/api/albums` on Immich, and
|
||||||
// `/api/photos/_officer/assets/<id>/thumbnail?size=preview` streams the thumbnail bytes back, Range and
|
// `/api/photos/_officer/assets/<id>/thumbnail?size=preview` streams the thumbnail bytes back, Range and
|
||||||
@@ -44,39 +57,52 @@ const server = Bun.serve({
|
|||||||
maxRequestBodySize: 4 * 1024 * 1024 * 1024,
|
maxRequestBodySize: 4 * 1024 * 1024 * 1024,
|
||||||
async fetch(req) {
|
async fetch(req) {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const cfg = getConfig();
|
|
||||||
|
|
||||||
if (url.pathname === '/_health') {
|
const officerUser = req.headers.get('X-Officer-User');
|
||||||
if (!cfg) return Response.json({ ok: false, error: 'IMMICH_URL/IMMICH_API_KEY not configured' }, { status: 503 });
|
const userId = Number(officerUser);
|
||||||
const started = Date.now();
|
if (!officerUser || !Number.isInteger(userId) || userId <= 0) {
|
||||||
|
return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/_config' || url.pathname.startsWith('/_config/')) {
|
||||||
try {
|
try {
|
||||||
// Version is public, so it separates "instance down" from "key rejected" in one shot.
|
return await handleConfigRoute(req, userId, url.pathname.slice('/_config'.length));
|
||||||
const [versionRes, meRes] = await Promise.all([
|
|
||||||
callUpstream(cfg, { path: '/api/server/version', withKey: false }),
|
|
||||||
callUpstream(cfg, { path: '/api/users/me' }),
|
|
||||||
]);
|
|
||||||
if (!versionRes.ok) {
|
|
||||||
return Response.json({ ok: false, error: `upstream returned ${versionRes.status}` }, { status: 502 });
|
|
||||||
}
|
|
||||||
const v = (await versionRes.json()) as { major?: number; minor?: number; patch?: number };
|
|
||||||
const version = [v.major, v.minor, v.patch].every((n) => typeof n === 'number')
|
|
||||||
? `${v.major}.${v.minor}.${v.patch}`
|
|
||||||
: null;
|
|
||||||
if (!meRes.ok) {
|
|
||||||
return Response.json(
|
|
||||||
{ ok: false, version, error: `IMMICH_API_KEY rejected (${meRes.status})`, ms: Date.now() - started },
|
|
||||||
{ status: 502 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const me = (await meRes.json()) as { email?: string; name?: string };
|
|
||||||
return Response.json({ ok: true, version, user: me.email ?? me.name ?? null, ms: Date.now() - started });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return Response.json({ ok: false, error: String(err), ms: Date.now() - started }, { status: 502 });
|
console.error(`[photos] ${req.method} ${url.pathname} failed`, err);
|
||||||
|
return Response.json({ error: 'internal error' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cfg = await getConfig(userId);
|
||||||
|
|
||||||
|
// 503 with `configured: false` is the signal the UI turns into the setup form. Distinguishing it from a
|
||||||
|
// configured-but-broken instance is the whole reason the flag is on the response.
|
||||||
|
if (url.pathname === '/_health') {
|
||||||
|
if (!cfg) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 });
|
||||||
|
|
||||||
|
const started = Date.now();
|
||||||
|
const result = await probe(cfg);
|
||||||
|
const ms = Date.now() - started;
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return Response.json(
|
||||||
|
{ ok: false, configured: true, account: cfg.label, version: result.version, error: result.error, ms },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await noteProbe(userId, cfg.id, result.version);
|
||||||
|
return Response.json({
|
||||||
|
ok: true,
|
||||||
|
configured: true,
|
||||||
|
account: cfg.label,
|
||||||
|
version: result.version,
|
||||||
|
user: result.user,
|
||||||
|
ms,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (url.pathname.startsWith('/_officer/')) {
|
if (url.pathname.startsWith('/_officer/')) {
|
||||||
if (!cfg) return Response.json({ error: 'photos not configured' }, { status: 503 });
|
if (!cfg) return Response.json({ error: 'photos not connected', configured: false }, { status: 503 });
|
||||||
try {
|
try {
|
||||||
const res = await handleOfficerRoute(cfg, req, url);
|
const res = await handleOfficerRoute(cfg, req, url);
|
||||||
if (res) return res;
|
if (res) return res;
|
||||||
@@ -91,7 +117,7 @@ const server = Bun.serve({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[photos] listening on 127.0.0.1:${port} -> ${getBase() ?? '(IMMICH_URL unset)'}`);
|
console.log(`[photos] listening on 127.0.0.1:${port} (instance configured from the UI, stored in photos_config)`);
|
||||||
|
|
||||||
type ReplyFn = (msg: SidecarEvent) => void;
|
type ReplyFn = (msg: SidecarEvent) => void;
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
// Immich upstream config for the officer-photos sidecar.
|
// Immich upstream config for the officer-photos sidecar.
|
||||||
//
|
//
|
||||||
// All knowledge of the Immich instance — its URL and its API key — lives here, mirroring
|
// All knowledge of the Immich instance — its URL and its API key — lives here. The platform API is a thin
|
||||||
// officer-invoiceshelf/officer-transmission/officer-slskd: the platform API is a thin auth+forward proxy
|
// auth+forward proxy and holds NO Immich credentials.
|
||||||
// and holds NO Immich credentials.
|
//
|
||||||
|
// The instance is CONFIGURED BY THE OWNER FROM THE UI and stored encrypted in `photos_config` (see
|
||||||
|
// databases/officer_db/src/queries/photos.ts). It is deliberately no longer read from the environment:
|
||||||
|
// Bun auto-loads `.env` into every process started in the platform directory, so an `IMMICH_API_KEY` there
|
||||||
|
// was also sitting in `officer`'s own `process.env` — a credential held by the one process that has no code
|
||||||
|
// to use it and the largest attack surface in the system. Nothing in this file reads process.env.
|
||||||
//
|
//
|
||||||
// Two things about Immich's API are load-bearing:
|
// Two things about Immich's API are load-bearing:
|
||||||
//
|
//
|
||||||
@@ -14,33 +19,46 @@
|
|||||||
// exactly the confusion this sidecar exists to prevent. Bun's fetch adds none of them on its own and
|
// exactly the confusion this sidecar exists to prevent. Bun's fetch adds none of them on its own and
|
||||||
// nothing below adds them; the platform proxy forwards only content-type, range and if-none-match.
|
// nothing below adds them; the platform proxy forwards only content-type, range and if-none-match.
|
||||||
|
|
||||||
const { IMMICH_URL, IMMICH_API_KEY } = process.env;
|
import { getActivePhotosCredentials } from 'officerdb';
|
||||||
|
|
||||||
export type UpstreamConfig = { base: string; key: string };
|
/** Everything needed to make one call. A candidate being validated has this and nothing else yet. */
|
||||||
|
export type UpstreamTarget = { base: string; key: string };
|
||||||
let warnedUnset = false;
|
|
||||||
|
|
||||||
/** The instance URL alone, for logging — set without a key is a real state and should read as one. */
|
|
||||||
export function getBase(): string | null {
|
|
||||||
return IMMICH_URL?.trim().replace(/\/+$/, '') || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The configured instance, or null when unconfigured — the sidecar then answers 503 rather than pretending
|
* A stored account, which is where every real call goes.
|
||||||
* to work. Warns once so a misconfigured deployment is obvious in the logs without flooding them.
|
*
|
||||||
|
* `id` and `label` ride along because the owner can have several accounts registered and only one selected:
|
||||||
|
* a probe has to be recorded against the row it actually reached, and a log line saying which library
|
||||||
|
* answered is the difference between "photos is broken" and "you are looking at the other account".
|
||||||
*/
|
*/
|
||||||
export function getConfig(): UpstreamConfig | null {
|
export type UpstreamConfig = UpstreamTarget & { id: number; label: string };
|
||||||
const base = IMMICH_URL?.trim().replace(/\/+$/, '');
|
|
||||||
const key = IMMICH_API_KEY?.trim();
|
/** Trailing slashes off, so `${base}/api/...` never doubles the separator. */
|
||||||
if (!base || !key) {
|
export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, '');
|
||||||
if (!warnedUnset) {
|
|
||||||
const missing = [!base && 'IMMICH_URL', !key && 'IMMICH_API_KEY'].filter(Boolean).join(' and ');
|
// A thumbnail grid is a hundred requests in a second and each one needs the key, so the row is cached rather
|
||||||
console.warn(`[photos] ${missing} unset — the sidecar will respond 503 until set`);
|
// than re-read per request. Writes invalidate immediately; the TTL only covers someone editing the row in
|
||||||
warnedUnset = true;
|
// psql, which then takes effect within a minute instead of needing a restart.
|
||||||
}
|
const TTL_MS = 60_000;
|
||||||
return null;
|
const cache = new Map<number, { cfg: UpstreamConfig | null; at: number }>();
|
||||||
}
|
|
||||||
return { base, key };
|
/**
|
||||||
|
* The owner's SELECTED account, or null when /photos has no account yet — the sidecar then answers 503,
|
||||||
|
* and the UI turns that into the setup form rather than a wall of empty grids.
|
||||||
|
*/
|
||||||
|
export async function getConfig(userId: number): Promise<UpstreamConfig | null> {
|
||||||
|
const hit = cache.get(userId);
|
||||||
|
if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg;
|
||||||
|
|
||||||
|
const creds = await getActivePhotosCredentials(userId);
|
||||||
|
const cfg = creds ? { id: creds.id, label: creds.label, base: normalizeBase(creds.url), key: creds.apiKey } : null;
|
||||||
|
cache.set(userId, { cfg, at: Date.now() });
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop the cached row — called by the config routes after any add, edit, switch or removal. */
|
||||||
|
export function invalidateConfig(userId: number): void {
|
||||||
|
cache.delete(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
type CallOptions = {
|
type CallOptions = {
|
||||||
@@ -59,7 +77,7 @@ type CallOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** The single door to Immich. Everything the sidecar fetches goes through here. */
|
/** The single door to Immich. Everything the sidecar fetches goes through here. */
|
||||||
export async function callUpstream(cfg: UpstreamConfig, opts: CallOptions): Promise<Response> {
|
export async function callUpstream(cfg: UpstreamTarget, opts: CallOptions): Promise<Response> {
|
||||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||||
|
|
||||||
if (opts.withKey !== false) headers['x-api-key'] = cfg.key;
|
if (opts.withKey !== false) headers['x-api-key'] = cfg.key;
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { Link } from 'react-router';
|
||||||
|
import { Check, ChevronsUpDown, Loader2, Settings2 } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { photosSectionPath } from './shared';
|
||||||
|
import { usePhotosAccountActions, usePhotosAccounts, usePhotosHealth } from './usePhotosData';
|
||||||
|
|
||||||
|
// Which Immich library you are looking at, and how to change it.
|
||||||
|
//
|
||||||
|
// The subtitle under "Photos" was already the connection state, so the switcher lives there rather than
|
||||||
|
// adding a second control saying nearly the same thing. With one account it is exactly what it was: a line
|
||||||
|
// of text plus a way into the settings screen.
|
||||||
|
//
|
||||||
|
// Switching invalidates the whole ['photos'] key, because it changes the answer to every query in the
|
||||||
|
// workspace without changing any of their inputs — the grid, the albums, the map are all the other library's
|
||||||
|
// now. Anything less and the previous account's thumbnails stay on screen.
|
||||||
|
|
||||||
|
export const AccountSwitcher = () => {
|
||||||
|
const { data } = usePhotosAccounts();
|
||||||
|
const { data: health } = usePhotosHealth();
|
||||||
|
const { activate } = usePhotosAccountActions();
|
||||||
|
|
||||||
|
const accounts = data?.accounts ?? [];
|
||||||
|
const active = accounts.find((account) => account.isActive) ?? null;
|
||||||
|
|
||||||
|
// Before the registry answers, fall back to health — it is the query that was already driving this line.
|
||||||
|
const status = active?.label ?? (health?.ok ? (health.version ?? 'connected') : 'not connected');
|
||||||
|
|
||||||
|
if (accounts.length < 2) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={photosSectionPath('settings')}
|
||||||
|
className="block truncate text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground">
|
||||||
|
<span className="truncate">{status}</span>
|
||||||
|
{activate.isPending ? (
|
||||||
|
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ChevronsUpDown className="h-3 w-3 shrink-0" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-60">
|
||||||
|
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">Immich accounts</DropdownMenuLabel>
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={account.id}
|
||||||
|
disabled={account.isActive || activate.isPending}
|
||||||
|
onSelect={() => void activate.mutateAsync(account.id).catch(() => undefined)}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Check className={`h-3.5 w-3.5 shrink-0 ${account.isActive ? 'opacity-100' : 'opacity-0'}`} />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate">{account.label}</span>
|
||||||
|
<span className="block truncate text-[11px] text-muted-foreground">{account.url}</span>
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to={photosSectionPath('settings')} className="gap-2">
|
||||||
|
<Settings2 className="h-3.5 w-3.5" />
|
||||||
|
Manage accounts
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import type { PhotosAccount } from './usePhotosData';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Check, CheckCircle2, Loader2, Plug, Trash2, TriangleAlert } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { formatShortDate } from './shared';
|
||||||
|
import { photosErrorMessage, usePhotosAccountActions, usePhotosAccounts, usePhotosHealth } from './usePhotosData';
|
||||||
|
|
||||||
|
// Connecting Immich libraries to Officer, from the app.
|
||||||
|
//
|
||||||
|
// This screen is BOTH the setup wizard and the permanent settings page: PhotosView renders it in place of
|
||||||
|
// whatever section the URL asks for while nothing is connected, and /photos/settings renders it for good.
|
||||||
|
// One component, so a second account added later goes through exactly the code path that stored the first.
|
||||||
|
//
|
||||||
|
// It is a registry, not a single connection — two keys against the same instance (one per Immich user) is
|
||||||
|
// the ordinary case, which is why each account carries a LABEL and why the label, not the URL, is what has
|
||||||
|
// to be unique. The API key is never displayed, because Officer cannot display it: it is encrypted at rest
|
||||||
|
// and the sidecar's GET has no field that could carry it back.
|
||||||
|
|
||||||
|
const HINT = 'text-[11px] leading-relaxed text-muted-foreground';
|
||||||
|
|
||||||
|
/** Immich's own default — its compose file publishes 2283, and the sidecar dials from this machine. */
|
||||||
|
const DEFAULT_URL = 'http://localhost:2283';
|
||||||
|
|
||||||
|
const URL_HINT =
|
||||||
|
"The instance's base URL, without /api. Officer reaches it from the server, not from this browser — so " +
|
||||||
|
'localhost here means the machine Officer runs on, and a local instance needs no TLS.';
|
||||||
|
|
||||||
|
const KEY_HINT =
|
||||||
|
'Immich → Account Settings → API Keys. Grant all permissions unless you have a reason not to: Immich keys ' +
|
||||||
|
'are scoped, and a partial key looks like a broken feature rather than a rejected credential.';
|
||||||
|
|
||||||
|
type FieldProps = {
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
type?: string;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Field = ({ label, hint, value, onChange, placeholder, type, autoFocus }: FieldProps) => (
|
||||||
|
<label className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-xs font-medium">{label}</span>
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(ev) => onChange(ev.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
type={type}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
{hint && <span className={HINT}>{hint}</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
type AccountRowProps = { account: PhotosAccount };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One stored account. The active one carries the live health line, because health only ever describes the
|
||||||
|
* account actually being used — showing a status next to the others would be inventing one.
|
||||||
|
*/
|
||||||
|
const AccountRow = ({ account }: AccountRowProps) => {
|
||||||
|
const { data: health, refetch: recheck, isFetching: checking } = usePhotosHealth();
|
||||||
|
const { edit, activate, remove } = usePhotosAccountActions();
|
||||||
|
|
||||||
|
const [apiKey, setApiKey] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// Removing an account throws away a key Officer can never show again, so the bin asks once.
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
|
||||||
|
const run = async (action: Promise<unknown>) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await action;
|
||||||
|
setApiKey('');
|
||||||
|
} catch (err) {
|
||||||
|
setError(photosErrorMessage(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col gap-2 rounded-lg border p-4 text-xs ${account.isActive ? 'border-primary/40' : ''}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{account.isActive ? <Check className="h-4 w-4 shrink-0 text-primary" /> : <span className="h-4 w-4 shrink-0" />}
|
||||||
|
<span className="truncate font-medium">{account.label}</span>
|
||||||
|
{account.isActive && (
|
||||||
|
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary">in use</span>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
{!account.isActive && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7"
|
||||||
|
disabled={activate.isPending}
|
||||||
|
onClick={() => void run(activate.mutateAsync(account.id))}
|
||||||
|
>
|
||||||
|
Use
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{account.isActive && (
|
||||||
|
<Button variant="ghost" size="sm" className="h-7" onClick={() => void recheck()}>
|
||||||
|
{checking ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Test'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={`h-7 ${confirming ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}`}
|
||||||
|
disabled={remove.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (!confirming) return setConfirming(true);
|
||||||
|
setConfirming(false);
|
||||||
|
void run(remove.mutateAsync(account.id));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{confirming ? 'Remove?' : <Trash2 className="h-3.5 w-3.5" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="truncate text-muted-foreground">{account.url}</p>
|
||||||
|
|
||||||
|
{account.isActive ? (
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
{health?.ok ? (
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<TriangleAlert className="h-3.5 w-3.5 text-amber-500" />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{health?.ok
|
||||||
|
? `Immich ${health.version ?? '?'}${health.user ? ` · ${health.user}` : ''}`
|
||||||
|
: (health?.error ?? 'not checked yet')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{account.version ? `Immich ${account.version}` : 'version unknown'}
|
||||||
|
{account.lastSeenAt ? ` · last answered ${formatShortDate(account.lastSeenAt)}` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(ev) => setApiKey(ev.target.value)}
|
||||||
|
placeholder="Replace API key"
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 shrink-0"
|
||||||
|
disabled={!apiKey.trim() || edit.isPending}
|
||||||
|
onClick={() => void run(edit.mutateAsync({ id: account.id, apiKey: apiKey.trim() }))}
|
||||||
|
>
|
||||||
|
{edit.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ConnectionSection = () => {
|
||||||
|
const { data, isLoading } = usePhotosAccounts();
|
||||||
|
const { add } = usePhotosAccountActions();
|
||||||
|
|
||||||
|
const [label, setLabel] = useState('');
|
||||||
|
const [url, setUrl] = useState(DEFAULT_URL);
|
||||||
|
const [apiKey, setApiKey] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const accounts = data?.accounts ?? [];
|
||||||
|
const hasAccounts = accounts.length > 0;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setError(null);
|
||||||
|
if (!url.trim()) return setError('The Immich URL is required');
|
||||||
|
if (!apiKey.trim()) return setError('An API key is required');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await add.mutateAsync({ label: label.trim(), url: url.trim(), apiKey: apiKey.trim() });
|
||||||
|
setLabel('');
|
||||||
|
setApiKey('');
|
||||||
|
setUrl(DEFAULT_URL);
|
||||||
|
} catch (err) {
|
||||||
|
setError(photosErrorMessage(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto">
|
||||||
|
<div className="mx-auto flex max-w-xl flex-col gap-4 p-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-emerald-500/15 text-emerald-400">
|
||||||
|
<Plug className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold">{hasAccounts ? 'Immich accounts' : 'Connect Immich'}</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{hasAccounts
|
||||||
|
? 'Officer stores each instance and its key encrypted, for this account only. One is in use at a time.'
|
||||||
|
: 'Photos needs an Immich instance and an API key before it can show anything.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isLoading && accounts.map((account) => <AccountRow key={account.id} account={account} />)}
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={(ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
void submit();
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-3 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<p className="text-xs font-medium">{hasAccounts ? 'Add another account' : 'Add an account'}</p>
|
||||||
|
<Field
|
||||||
|
label="Label"
|
||||||
|
value={label}
|
||||||
|
onChange={setLabel}
|
||||||
|
placeholder="Optional — defaults to the Immich user"
|
||||||
|
hint="What the switcher calls this account. Two accounts cannot share a label; two can share a URL."
|
||||||
|
autoFocus={hasAccounts}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Immich URL"
|
||||||
|
value={url}
|
||||||
|
onChange={setUrl}
|
||||||
|
placeholder={DEFAULT_URL}
|
||||||
|
hint={URL_HINT}
|
||||||
|
autoFocus={!hasAccounts}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="API key"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={setApiKey}
|
||||||
|
placeholder="••••••••••••"
|
||||||
|
type="password"
|
||||||
|
hint={KEY_HINT}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button type="submit" size="sm" disabled={add.isPending}>
|
||||||
|
{add.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||||
|
{add.isPending ? 'Verifying…' : hasAccounts ? 'Add account' : 'Connect'}
|
||||||
|
</Button>
|
||||||
|
{add.isPending && <span className={HINT}>Checking the instance and the key…</span>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { NavLink } from 'react-router';
|
import { NavLink } from 'react-router';
|
||||||
import { Archive, Compass, Heart, Images, Map as MapIcon, Search, Share2, Trash2, Users } from 'lucide-react';
|
import { Archive, Compass, Heart, Images, Map as MapIcon, Plug, Search, Share2, Trash2, Users } from 'lucide-react';
|
||||||
|
import { AccountSwitcher } from './AccountSwitcher';
|
||||||
import { PHOTOS_SECTIONS, photosSectionPath, type PhotosSectionId } from './shared';
|
import { PHOTOS_SECTIONS, photosSectionPath, type PhotosSectionId } from './shared';
|
||||||
import { useAssetStats, usePhotosHealth } from './usePhotosData';
|
import { useAssetStats } from './usePhotosData';
|
||||||
|
|
||||||
// Left panel of the /photos workspace, mirroring Immich's own sidebar: library sections up top, the sharing
|
// Left panel of the /photos workspace, mirroring Immich's own sidebar: library sections up top, the sharing
|
||||||
// and cleanup ones below a divider, and the library size at the bottom.
|
// and cleanup ones below a divider, and the library size at the bottom.
|
||||||
@@ -20,6 +21,7 @@ const ICONS: Record<PhotosSectionId, LucideIcon> = {
|
|||||||
archive: Archive,
|
archive: Archive,
|
||||||
sharing: Share2,
|
sharing: Share2,
|
||||||
trash: Trash2,
|
trash: Trash2,
|
||||||
|
settings: Plug,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Immich draws the same line: browsing the library, then everything else.
|
// Immich draws the same line: browsing the library, then everything else.
|
||||||
@@ -29,7 +31,6 @@ const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-le
|
|||||||
|
|
||||||
export const PhotosNav = () => {
|
export const PhotosNav = () => {
|
||||||
const { data: stats } = useAssetStats();
|
const { data: stats } = useAssetStats();
|
||||||
const { data: health } = usePhotosHealth();
|
|
||||||
|
|
||||||
const group = (ids: PhotosSectionId[]) =>
|
const group = (ids: PhotosSectionId[]) =>
|
||||||
PHOTOS_SECTIONS.filter((section) => ids.includes(section.id)).map(({ id, label }) => {
|
PHOTOS_SECTIONS.filter((section) => ids.includes(section.id)).map(({ id, label }) => {
|
||||||
@@ -71,9 +72,7 @@ export const PhotosNav = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-sm font-semibold leading-tight">Photos</div>
|
<div className="truncate text-sm font-semibold leading-tight">Photos</div>
|
||||||
<div className="truncate text-xs text-muted-foreground">
|
<AccountSwitcher />
|
||||||
{health?.ok ? (health.version ?? 'connected') : 'not connected'}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,43 @@
|
|||||||
|
import { Link } from 'react-router';
|
||||||
import { AlbumsSection } from './AlbumsSection';
|
import { AlbumsSection } from './AlbumsSection';
|
||||||
|
import { ConnectionSection } from './ConnectionSection';
|
||||||
import { ExploreSection } from './ExploreSection';
|
import { ExploreSection } from './ExploreSection';
|
||||||
import { MapSection } from './MapSection';
|
import { MapSection } from './MapSection';
|
||||||
import { PeopleSection } from './PeopleSection';
|
import { PeopleSection } from './PeopleSection';
|
||||||
import { SearchSection } from './SearchSection';
|
import { SearchSection } from './SearchSection';
|
||||||
import { SharingSection } from './SharingSection';
|
import { SharingSection } from './SharingSection';
|
||||||
import { TimelineSection } from './TimelineSection';
|
import { TimelineSection } from './TimelineSection';
|
||||||
|
import { photosSectionPath } from './shared';
|
||||||
import { usePhotosHealth } from './usePhotosData';
|
import { usePhotosHealth } from './usePhotosData';
|
||||||
import { usePhotosSection } from './usePhotosSection';
|
import { usePhotosSection } from './usePhotosSection';
|
||||||
|
|
||||||
// Right panel of the /photos workspace — renders the section named by the URL.
|
// Right panel of the /photos workspace — renders the section named by the URL.
|
||||||
//
|
//
|
||||||
// Timeline, favorites, archive and trash are one component with a filter; the rest are their own.
|
// Timeline, favorites, archive and trash are one component with a filter; the rest are their own.
|
||||||
|
//
|
||||||
|
// Health gates all of them, and its two failure modes are answered differently: nothing configured yet is
|
||||||
|
// the setup form, whatever section was asked for, because there is nothing else useful to show; a stored
|
||||||
|
// instance that is failing keeps its own message and a way back to the connection screen, since replacing a
|
||||||
|
// working key by accident is worse than a wall of text.
|
||||||
|
|
||||||
export const PhotosView = () => {
|
export const PhotosView = () => {
|
||||||
const section = usePhotosSection();
|
const section = usePhotosSection();
|
||||||
const { data: health, isLoading } = usePhotosHealth();
|
const { data: health, isLoading } = usePhotosHealth();
|
||||||
|
|
||||||
// Every section is useless without the upstream, and a wall of empty grids is a worse answer than saying so.
|
if (section === 'settings') return <ConnectionSection />;
|
||||||
|
|
||||||
if (!isLoading && health && !health.ok) {
|
if (!isLoading && health && !health.ok) {
|
||||||
|
if (health.configured === false) return <ConnectionSection />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center">
|
<div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center">
|
||||||
<p className="text-sm font-medium">Photos is not connected</p>
|
<p className="text-sm font-medium">Photos is not answering</p>
|
||||||
<p className="max-w-sm text-xs text-muted-foreground">
|
<p className="max-w-sm text-xs text-muted-foreground">
|
||||||
{health.error ?? 'The photos sidecar could not reach Immich. Check IMMICH_URL and IMMICH_API_KEY.'}
|
{health.error ?? 'The photos sidecar could not reach the configured Immich instance.'}
|
||||||
</p>
|
</p>
|
||||||
|
<Link to={photosSectionPath('settings')} className="mt-2 text-xs font-medium text-primary hover:underline">
|
||||||
|
Check the connection
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const PHOTOS_SECTIONS = [
|
|||||||
{ id: 'archive', label: 'Archive' },
|
{ id: 'archive', label: 'Archive' },
|
||||||
{ id: 'sharing', label: 'Sharing' },
|
{ id: 'sharing', label: 'Sharing' },
|
||||||
{ id: 'trash', label: 'Trash' },
|
{ id: 'trash', label: 'Trash' },
|
||||||
|
{ id: 'settings', label: 'Connection' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type PhotosSectionId = (typeof PHOTOS_SECTIONS)[number]['id'];
|
export type PhotosSectionId = (typeof PHOTOS_SECTIONS)[number]['id'];
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type {
|
|||||||
TimeBucketColumns,
|
TimeBucketColumns,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useQueries, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { bucketAssets } from './shared';
|
import { bucketAssets } from './shared';
|
||||||
|
|
||||||
@@ -234,14 +234,125 @@ export function useServerConfig() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PhotosHealth = { ok: boolean; version?: string | null; user?: string | null; error?: string };
|
// ── Accounts ──────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Immich instances and their API keys are the owner's to set from /photos/settings; nothing reads them from
|
||||||
|
// the environment any more. It is a REGISTRY — any number of labelled accounts, one of them selected — so
|
||||||
|
// two keys against the same instance (one per Immich user) is an ordinary thing to have.
|
||||||
|
//
|
||||||
|
// The key is WRITE-ONLY across this boundary: an account carries its label, URL and whether it is selected,
|
||||||
|
// and has no field that could carry the key back to the browser.
|
||||||
|
|
||||||
|
export type PhotosHealth = {
|
||||||
|
ok: boolean;
|
||||||
|
/** False only when no account is stored. It is what separates "set this up" from "this used to work". */
|
||||||
|
configured?: boolean;
|
||||||
|
/** Label of the selected account, so a failure names which library did not answer. */
|
||||||
|
account?: string;
|
||||||
|
version?: string | null;
|
||||||
|
user?: string | null;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PhotosAccount = {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
version: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
lastSeenAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PhotosAccounts = { configured: boolean; activeId: number | null; accounts: PhotosAccount[] };
|
||||||
|
|
||||||
|
/** Unwrap the `{ status, message }` useClient throws, where `message` is the sidecar's JSON body. */
|
||||||
|
export function photosErrorMessage(err: unknown): string {
|
||||||
|
const raw = (err as { message?: unknown } | null)?.message;
|
||||||
|
if (typeof raw !== 'string' || !raw) return 'Something went wrong';
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as { error?: unknown };
|
||||||
|
if (typeof parsed.error === 'string' && parsed.error) return parsed.error;
|
||||||
|
} catch {
|
||||||
|
/* plain text */
|
||||||
|
}
|
||||||
|
return raw.slice(0, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health, including its failure bodies.
|
||||||
|
*
|
||||||
|
* `get` throws on any status >= 400, so a plain query would leave `data` undefined for exactly the two
|
||||||
|
* cases the UI most needs to tell apart — 503 not connected and 502 connected-but-broken. Both carry a
|
||||||
|
* JSON body, so the throw is turned back into the answer rather than an error state.
|
||||||
|
*/
|
||||||
export function usePhotosHealth() {
|
export function usePhotosHealth() {
|
||||||
const { get } = useClient();
|
const { get } = useClient();
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: [KEY, 'health'],
|
queryKey: [KEY, 'health'],
|
||||||
queryFn: () => get<PhotosHealth>('/photos/_health'),
|
queryFn: async (): Promise<PhotosHealth> => {
|
||||||
|
try {
|
||||||
|
return await get<PhotosHealth>('/photos/_health');
|
||||||
|
} catch (err) {
|
||||||
|
const raw = (err as { message?: unknown } | null)?.message;
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(raw) as PhotosHealth;
|
||||||
|
if (body && body.ok === false) return body;
|
||||||
|
} catch {
|
||||||
|
/* not the sidecar's body */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Anything else — the platform proxy, auth, the sidecar being down — is a configured instance
|
||||||
|
// that is failing, not an unconfigured one. Never offer the setup form on a guess.
|
||||||
|
return { ok: false, configured: true, error: photosErrorMessage(err) };
|
||||||
|
}
|
||||||
|
},
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function usePhotosAccounts() {
|
||||||
|
const { get } = useClient();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [KEY, 'accounts'],
|
||||||
|
queryFn: () => get<PhotosAccounts>('/photos/_config'),
|
||||||
|
staleTime: 60_000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AddPhotosAccountInput = { label: string; url: string; apiKey: string };
|
||||||
|
export type EditPhotosAccountInput = { id: number; label?: string; url?: string; apiKey?: string };
|
||||||
|
|
||||||
|
export function usePhotosAccountActions() {
|
||||||
|
const { post, patch, delete: del } = useClient();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
// Adding, editing, switching and removing all change what every other query in this workspace can even
|
||||||
|
// answer — a switch in particular changes the answer to all of them without changing any of their inputs.
|
||||||
|
const invalidate = () => qc.invalidateQueries({ queryKey: [KEY] });
|
||||||
|
|
||||||
|
const add = useMutation({
|
||||||
|
mutationFn: (input: AddPhotosAccountInput) => post<{ account: PhotosAccount }>('/photos/_config', input),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const edit = useMutation({
|
||||||
|
mutationFn: ({ id, ...rest }: EditPhotosAccountInput) =>
|
||||||
|
patch<{ account: PhotosAccount }>(`/photos/_config/${id}`, rest),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const activate = useMutation({
|
||||||
|
mutationFn: (id: number) => post<PhotosAccounts>(`/photos/_config/${id}/activate`, {}),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (id: number) => del<PhotosAccounts>(`/photos/_config/${id}`),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { add, edit, activate, remove };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user