Refactor installation process and update schema definitions for improved functionality
This commit is contained in:
parent
4631f82ee4
commit
8756010173
9
NuGet.Config
Normal file
9
NuGet.Config
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<config>
|
||||||
|
<add key="globalPackagesFolder" value=".nuget/packages" />
|
||||||
|
</config>
|
||||||
|
<packageSources>
|
||||||
|
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
||||||
|
</packageSources>
|
||||||
|
</configuration>
|
||||||
@ -21,22 +21,12 @@
|
|||||||
- 強制重設 superuser 密碼
|
- 強制重設 superuser 密碼
|
||||||
- 只執行 migration(差異安裝),不重建 schema
|
- 只執行 migration(差異安裝),不重建 schema
|
||||||
|
|
||||||
## 建議實作方式(.NET)
|
## CLI 指令規格(實作)
|
||||||
- 提供一個 CLI 或安裝頁面(類 nopCommerce / Wagtail)
|
|
||||||
- CLI 支援互動式輸入(DB 連線、admin 帳號、密碼)
|
|
||||||
- 內部流程一致:
|
|
||||||
1) 讀取/寫入連線資訊
|
|
||||||
2) 執行 migration(`dbContext.Database.Migrate()`)
|
|
||||||
3) 建立角色與 superuser(若不存在)
|
|
||||||
|
|
||||||
## CLI 指令規格(草案)
|
|
||||||
|
|
||||||
### 通用參數
|
### 通用參數
|
||||||
- `--connection-string <string>`: DB 連線字串
|
- `--connection-string <string>`: DB 連線字串
|
||||||
- `--db-provider <string>`: 預設 `postgres`
|
- `--appsettings <path>`: 設定檔路徑(讀/寫 `ConnectionStrings:Default`)
|
||||||
- `--appsettings <path>`: 設定檔路徑(預設 `appsettings.json`)
|
|
||||||
- `--no-prompt`: 不使用互動輸入(CI/CD)
|
- `--no-prompt`: 不使用互動輸入(CI/CD)
|
||||||
- `--dry-run`: 只檢查,不寫入
|
|
||||||
- `--verbose`: 詳細輸出
|
- `--verbose`: 詳細輸出
|
||||||
|
|
||||||
### 1) `installer init`
|
### 1) `installer init`
|
||||||
@ -49,11 +39,13 @@
|
|||||||
- `--force`: 若偵測已初始化,仍強制執行
|
- `--force`: 若偵測已初始化,仍強制執行
|
||||||
|
|
||||||
流程:
|
流程:
|
||||||
1) 讀取/寫入 DB 連線資訊
|
1) 解析連線字串(參數或 appsettings)
|
||||||
|
- 若提供 `--connection-string`,會寫入 appsettings
|
||||||
|
- 若 appsettings 中缺少連線字串,會互動式詢問並寫入
|
||||||
2) 執行 migrations(不 Drop)
|
2) 執行 migrations(不 Drop)
|
||||||
3) 建立 roles(admin, support)
|
3) 建立 roles(admin, support)
|
||||||
4) 建立 admin(不存在才建立)
|
4) 建立 admin(不存在才建立)並加入 admin 角色
|
||||||
5) 寫入安裝鎖定(install.lock 或 DB flag)
|
5) 寫入安裝鎖定(DB flag: `system_flags` / `installed=true`)
|
||||||
|
|
||||||
### 2) `installer add-admin`
|
### 2) `installer add-admin`
|
||||||
用途:新增 superuser
|
用途:新增 superuser
|
||||||
@ -64,7 +56,7 @@
|
|||||||
- `--admin-display-name <string>`
|
- `--admin-display-name <string>`
|
||||||
|
|
||||||
流程:
|
流程:
|
||||||
1) 檢查 DB 連線
|
1) 解析連線字串
|
||||||
2) 建立使用者並指派 admin 角色
|
2) 建立使用者並指派 admin 角色
|
||||||
|
|
||||||
### 3) `installer reset-admin-password`
|
### 3) `installer reset-admin-password`
|
||||||
@ -75,9 +67,8 @@
|
|||||||
- `--admin-password <string>`
|
- `--admin-password <string>`
|
||||||
|
|
||||||
流程:
|
流程:
|
||||||
1) 檢查帳號存在
|
1) 解析連線字串
|
||||||
2) 更新密碼(強制)
|
2) 更新密碼(強制)
|
||||||
3) 寫入 audit log
|
|
||||||
|
|
||||||
### 4) `installer migrate`
|
### 4) `installer migrate`
|
||||||
用途:只執行 migrations
|
用途:只執行 migrations
|
||||||
@ -86,9 +77,8 @@
|
|||||||
- `--target <migration>`: 指定遷移(可選)
|
- `--target <migration>`: 指定遷移(可選)
|
||||||
|
|
||||||
流程:
|
流程:
|
||||||
1) 連線檢查
|
1) 解析連線字串
|
||||||
2) 執行 migrations
|
2) 執行 migrations(可指定 target)
|
||||||
3) 輸出版本
|
|
||||||
|
|
||||||
## 安全注意
|
## 安全注意
|
||||||
- 密碼必須符合強度規則
|
- 密碼必須符合強度規則
|
||||||
|
|||||||
194
docs/SCHEMA.sql
194
docs/SCHEMA.sql
@ -1,4 +1,7 @@
|
|||||||
-- PostgreSQL schema draft (MVP)
|
-- PostgreSQL schema draft (MVP)
|
||||||
|
-- Note: OpenIddict/Identity tables are based on EF Core migrations.
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
|
||||||
CREATE TABLE tenants (
|
CREATE TABLE tenants (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
@ -10,62 +13,137 @@ CREATE TABLE tenants (
|
|||||||
|
|
||||||
CREATE TABLE users (
|
CREATE TABLE users (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
email TEXT NOT NULL UNIQUE,
|
user_name TEXT,
|
||||||
password_hash TEXT NOT NULL,
|
normalized_user_name TEXT,
|
||||||
email_verified_at TIMESTAMPTZ,
|
email TEXT,
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
normalized_email TEXT,
|
||||||
|
email_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
password_hash TEXT,
|
||||||
|
security_stamp TEXT,
|
||||||
|
concurrency_stamp TEXT,
|
||||||
|
phone_number TEXT,
|
||||||
|
phone_number_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
two_factor_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
lockout_end TIMESTAMPTZ,
|
||||||
|
lockout_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
access_failed_count INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE oauth_clients (
|
CREATE TABLE roles (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
name TEXT,
|
||||||
|
normalized_name TEXT,
|
||||||
|
concurrency_stamp TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_roles (
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (user_id, role_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_claims (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
claim_type TEXT,
|
||||||
|
claim_value TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE role_claims (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||||
|
claim_type TEXT,
|
||||||
|
claim_value TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_logins (
|
||||||
|
login_provider TEXT NOT NULL,
|
||||||
|
provider_key TEXT NOT NULL,
|
||||||
|
provider_display_name TEXT,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (login_provider, provider_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_tokens (
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
login_provider TEXT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
redirect_uris TEXT[] NOT NULL DEFAULT '{}',
|
value TEXT,
|
||||||
client_type TEXT NOT NULL DEFAULT 'confidential',
|
PRIMARY KEY (user_id, login_provider, name)
|
||||||
client_secret_hash TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
ALTER TABLE oauth_clients
|
CREATE UNIQUE INDEX idx_users_normalized_user_name ON users(normalized_user_name);
|
||||||
ADD CONSTRAINT chk_oauth_clients_secret
|
CREATE UNIQUE INDEX idx_users_normalized_email ON users(normalized_email);
|
||||||
CHECK (
|
CREATE UNIQUE INDEX idx_roles_normalized_name ON roles(normalized_name);
|
||||||
(client_type = 'confidential' AND client_secret_hash IS NOT NULL)
|
|
||||||
OR (client_type <> 'confidential')
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE oauth_codes (
|
-- OpenIddict (EF Core default tables)
|
||||||
id UUID PRIMARY KEY,
|
CREATE TABLE "OpenIddictApplications" (
|
||||||
client_id UUID NOT NULL REFERENCES oauth_clients(id),
|
"Id" UUID PRIMARY KEY,
|
||||||
user_id UUID NOT NULL REFERENCES users(id),
|
"ApplicationType" TEXT,
|
||||||
code_hash TEXT NOT NULL,
|
"ClientId" TEXT,
|
||||||
code_challenge TEXT NOT NULL,
|
"ClientSecret" TEXT,
|
||||||
code_challenge_method TEXT NOT NULL DEFAULT 'S256',
|
"ConsentType" TEXT,
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
"DisplayName" TEXT,
|
||||||
consumed_at TIMESTAMPTZ
|
"DisplayNames" JSONB,
|
||||||
|
"Permissions" JSONB,
|
||||||
|
"PostLogoutRedirectUris" JSONB,
|
||||||
|
"Properties" JSONB,
|
||||||
|
"RedirectUris" JSONB,
|
||||||
|
"Requirements" JSONB,
|
||||||
|
"ConcurrencyToken" TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE oauth_tokens (
|
CREATE TABLE "OpenIddictAuthorizations" (
|
||||||
id UUID PRIMARY KEY,
|
"Id" UUID PRIMARY KEY,
|
||||||
user_id UUID NOT NULL REFERENCES users(id),
|
"ApplicationId" UUID REFERENCES "OpenIddictApplications"("Id") ON DELETE SET NULL,
|
||||||
client_id UUID NOT NULL REFERENCES oauth_clients(id),
|
"CreationDate" TIMESTAMPTZ,
|
||||||
access_token_hash TEXT NOT NULL,
|
"Status" TEXT,
|
||||||
refresh_token_hash TEXT NOT NULL,
|
"Subject" TEXT,
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
"Type" TEXT,
|
||||||
revoked_at TIMESTAMPTZ
|
"Scopes" JSONB,
|
||||||
|
"Properties" JSONB,
|
||||||
|
"ConcurrencyToken" TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE oidc_id_tokens (
|
CREATE TABLE "OpenIddictScopes" (
|
||||||
id UUID PRIMARY KEY,
|
"Id" UUID PRIMARY KEY,
|
||||||
user_id UUID NOT NULL REFERENCES users(id),
|
"Description" TEXT,
|
||||||
client_id UUID NOT NULL REFERENCES oauth_clients(id),
|
"Descriptions" JSONB,
|
||||||
id_token_hash TEXT NOT NULL,
|
"DisplayName" TEXT,
|
||||||
expires_at TIMESTAMPTZ NOT NULL
|
"DisplayNames" JSONB,
|
||||||
|
"Name" TEXT,
|
||||||
|
"Properties" JSONB,
|
||||||
|
"Resources" JSONB,
|
||||||
|
"ConcurrencyToken" TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "OpenIddictTokens" (
|
||||||
|
"Id" UUID PRIMARY KEY,
|
||||||
|
"ApplicationId" UUID REFERENCES "OpenIddictApplications"("Id") ON DELETE SET NULL,
|
||||||
|
"AuthorizationId" UUID REFERENCES "OpenIddictAuthorizations"("Id") ON DELETE SET NULL,
|
||||||
|
"CreationDate" TIMESTAMPTZ,
|
||||||
|
"ExpirationDate" TIMESTAMPTZ,
|
||||||
|
"RedemptionDate" TIMESTAMPTZ,
|
||||||
|
"Payload" TEXT,
|
||||||
|
"ReferenceId" TEXT,
|
||||||
|
"Status" TEXT,
|
||||||
|
"Subject" TEXT,
|
||||||
|
"Type" TEXT,
|
||||||
|
"Properties" JSONB,
|
||||||
|
"ConcurrencyToken" TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_openiddict_applications_client_id ON "OpenIddictApplications"("ClientId");
|
||||||
|
CREATE UNIQUE INDEX idx_openiddict_scopes_name ON "OpenIddictScopes"("Name");
|
||||||
|
CREATE UNIQUE INDEX idx_openiddict_tokens_reference_id ON "OpenIddictTokens"("ReferenceId");
|
||||||
|
CREATE INDEX idx_openiddict_tokens_subject ON "OpenIddictTokens"("Subject");
|
||||||
|
|
||||||
CREATE TABLE newsletter_lists (
|
CREATE TABLE newsletter_lists (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
@ -73,9 +151,9 @@ CREATE TABLE newsletter_lists (
|
|||||||
|
|
||||||
CREATE TABLE newsletter_subscriptions (
|
CREATE TABLE newsletter_subscriptions (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
list_id UUID NOT NULL REFERENCES newsletter_lists(id),
|
list_id UUID NOT NULL REFERENCES newsletter_lists(id) ON DELETE CASCADE,
|
||||||
email TEXT NOT NULL,
|
email TEXT NOT NULL,
|
||||||
user_id UUID REFERENCES users(id),
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
status TEXT NOT NULL DEFAULT 'pending',
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
preferences JSONB NOT NULL DEFAULT '{}'::jsonb,
|
preferences JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
@ -85,7 +163,7 @@ CREATE TABLE newsletter_subscriptions (
|
|||||||
CREATE TABLE email_verifications (
|
CREATE TABLE email_verifications (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
email TEXT NOT NULL,
|
email TEXT NOT NULL,
|
||||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
token_hash TEXT NOT NULL,
|
token_hash TEXT NOT NULL,
|
||||||
purpose TEXT NOT NULL,
|
purpose TEXT NOT NULL,
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
@ -94,7 +172,7 @@ CREATE TABLE email_verifications (
|
|||||||
|
|
||||||
CREATE TABLE unsubscribe_tokens (
|
CREATE TABLE unsubscribe_tokens (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
subscription_id UUID NOT NULL REFERENCES newsletter_subscriptions(id),
|
subscription_id UUID NOT NULL REFERENCES newsletter_subscriptions(id) ON DELETE CASCADE,
|
||||||
token_hash TEXT NOT NULL,
|
token_hash TEXT NOT NULL,
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
consumed_at TIMESTAMPTZ
|
consumed_at TIMESTAMPTZ
|
||||||
@ -109,17 +187,11 @@ CREATE TABLE audit_logs (
|
|||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE roles (
|
CREATE TABLE system_flags (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
name TEXT NOT NULL UNIQUE,
|
key TEXT NOT NULL UNIQUE,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
value TEXT NOT NULL,
|
||||||
);
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
|
||||||
CREATE TABLE user_roles (
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id),
|
|
||||||
role_id UUID NOT NULL REFERENCES roles(id),
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
PRIMARY KEY (user_id, role_id)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_newsletter_subscriptions_email
|
CREATE INDEX idx_newsletter_subscriptions_email
|
||||||
@ -128,17 +200,5 @@ CREATE INDEX idx_newsletter_subscriptions_email
|
|||||||
CREATE INDEX idx_newsletter_subscriptions_list_id
|
CREATE INDEX idx_newsletter_subscriptions_list_id
|
||||||
ON newsletter_subscriptions(list_id);
|
ON newsletter_subscriptions(list_id);
|
||||||
|
|
||||||
CREATE INDEX idx_oauth_tokens_user_id
|
|
||||||
ON oauth_tokens(user_id);
|
|
||||||
|
|
||||||
CREATE INDEX idx_oauth_tokens_client_id
|
|
||||||
ON oauth_tokens(client_id);
|
|
||||||
|
|
||||||
CREATE INDEX idx_oauth_codes_client_id
|
|
||||||
ON oauth_codes(client_id);
|
|
||||||
|
|
||||||
CREATE INDEX idx_email_verifications_email
|
CREATE INDEX idx_email_verifications_email
|
||||||
ON email_verifications(email);
|
ON email_verifications(email);
|
||||||
|
|
||||||
CREATE INDEX idx_user_roles_role_id
|
|
||||||
ON user_roles(role_id);
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@ paths:
|
|||||||
get:
|
get:
|
||||||
summary: OAuth2 Authorization Endpoint
|
summary: OAuth2 Authorization Endpoint
|
||||||
description: Authorization Code + PKCE flow
|
description: Authorization Code + PKCE flow
|
||||||
|
security: []
|
||||||
parameters:
|
parameters:
|
||||||
- in: query
|
- in: query
|
||||||
name: client_id
|
name: client_id
|
||||||
@ -49,20 +50,15 @@ paths:
|
|||||||
/oauth/token:
|
/oauth/token:
|
||||||
post:
|
post:
|
||||||
summary: OAuth2 Token Endpoint
|
summary: OAuth2 Token Endpoint
|
||||||
|
security: []
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
application/x-www-form-urlencoded:
|
application/x-www-form-urlencoded:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
oneOf:
|
||||||
required: [grant_type, code, redirect_uri, client_id, code_verifier]
|
- $ref: '#/components/schemas/AuthorizationCodeTokenRequest'
|
||||||
properties:
|
- $ref: '#/components/schemas/RefreshTokenRequest'
|
||||||
grant_type: { type: string, enum: [authorization_code, refresh_token] }
|
|
||||||
code: { type: string }
|
|
||||||
redirect_uri: { type: string }
|
|
||||||
client_id: { type: string }
|
|
||||||
code_verifier: { type: string }
|
|
||||||
refresh_token: { type: string }
|
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Token response
|
description: Token response
|
||||||
@ -88,6 +84,7 @@ paths:
|
|||||||
/auth/register:
|
/auth/register:
|
||||||
post:
|
post:
|
||||||
summary: Register user
|
summary: Register user
|
||||||
|
security: []
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@ -105,12 +102,13 @@ paths:
|
|||||||
/auth/login:
|
/auth/login:
|
||||||
post:
|
post:
|
||||||
summary: API login
|
summary: API login
|
||||||
|
security: []
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/x-www-form-urlencoded:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/LoginRequest'
|
$ref: '#/components/schemas/PasswordTokenRequest'
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Token response
|
description: Token response
|
||||||
@ -122,12 +120,13 @@ paths:
|
|||||||
/auth/refresh:
|
/auth/refresh:
|
||||||
post:
|
post:
|
||||||
summary: Refresh token
|
summary: Refresh token
|
||||||
|
security: []
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/x-www-form-urlencoded:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/RefreshRequest'
|
$ref: '#/components/schemas/RefreshTokenRequest'
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Token response
|
description: Token response
|
||||||
@ -157,6 +156,7 @@ paths:
|
|||||||
/auth/password/forgot:
|
/auth/password/forgot:
|
||||||
post:
|
post:
|
||||||
summary: Request password reset
|
summary: Request password reset
|
||||||
|
security: []
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@ -170,6 +170,7 @@ paths:
|
|||||||
/auth/password/reset:
|
/auth/password/reset:
|
||||||
post:
|
post:
|
||||||
summary: Reset password
|
summary: Reset password
|
||||||
|
security: []
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@ -183,11 +184,16 @@ paths:
|
|||||||
/auth/email/verify:
|
/auth/email/verify:
|
||||||
get:
|
get:
|
||||||
summary: Verify email
|
summary: Verify email
|
||||||
|
security: []
|
||||||
parameters:
|
parameters:
|
||||||
- in: query
|
- in: query
|
||||||
name: token
|
name: token
|
||||||
required: true
|
required: true
|
||||||
schema: { type: string }
|
schema: { type: string }
|
||||||
|
- in: query
|
||||||
|
name: email
|
||||||
|
required: true
|
||||||
|
schema: { type: string, format: email }
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Email verified
|
description: Email verified
|
||||||
@ -208,6 +214,7 @@ paths:
|
|||||||
/newsletter/subscribe:
|
/newsletter/subscribe:
|
||||||
post:
|
post:
|
||||||
summary: Subscribe (unauthenticated allowed)
|
summary: Subscribe (unauthenticated allowed)
|
||||||
|
security: []
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@ -220,11 +227,12 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/Subscription'
|
$ref: '#/components/schemas/PendingSubscriptionResponse'
|
||||||
|
|
||||||
/newsletter/confirm:
|
/newsletter/confirm:
|
||||||
get:
|
get:
|
||||||
summary: Confirm subscription (double opt-in)
|
summary: Confirm subscription (double opt-in)
|
||||||
|
security: []
|
||||||
parameters:
|
parameters:
|
||||||
- in: query
|
- in: query
|
||||||
name: token
|
name: token
|
||||||
@ -241,6 +249,7 @@ paths:
|
|||||||
/newsletter/unsubscribe:
|
/newsletter/unsubscribe:
|
||||||
post:
|
post:
|
||||||
summary: Unsubscribe single list
|
summary: Unsubscribe single list
|
||||||
|
security: []
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@ -515,14 +524,25 @@ components:
|
|||||||
email: { type: string, format: email }
|
email: { type: string, format: email }
|
||||||
password: { type: string }
|
password: { type: string }
|
||||||
|
|
||||||
LoginRequest:
|
PasswordTokenRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [email, password, client_id]
|
required: [grant_type, username, password]
|
||||||
properties:
|
properties:
|
||||||
email: { type: string, format: email }
|
grant_type: { type: string, enum: [password] }
|
||||||
|
username: { type: string, format: email }
|
||||||
password: { type: string }
|
password: { type: string }
|
||||||
client_id: { type: string }
|
|
||||||
scope: { type: string }
|
scope: { type: string }
|
||||||
|
client_id: { type: string }
|
||||||
|
|
||||||
|
AuthorizationCodeTokenRequest:
|
||||||
|
type: object
|
||||||
|
required: [grant_type, code, redirect_uri, code_verifier]
|
||||||
|
properties:
|
||||||
|
grant_type: { type: string, enum: [authorization_code] }
|
||||||
|
code: { type: string }
|
||||||
|
redirect_uri: { type: string }
|
||||||
|
code_verifier: { type: string }
|
||||||
|
client_id: { type: string }
|
||||||
|
|
||||||
RefreshRequest:
|
RefreshRequest:
|
||||||
type: object
|
type: object
|
||||||
@ -531,6 +551,14 @@ components:
|
|||||||
refresh_token: { type: string }
|
refresh_token: { type: string }
|
||||||
client_id: { type: string }
|
client_id: { type: string }
|
||||||
|
|
||||||
|
RefreshTokenRequest:
|
||||||
|
type: object
|
||||||
|
required: [grant_type, refresh_token]
|
||||||
|
properties:
|
||||||
|
grant_type: { type: string, enum: [refresh_token] }
|
||||||
|
refresh_token: { type: string }
|
||||||
|
client_id: { type: string }
|
||||||
|
|
||||||
ForgotPasswordRequest:
|
ForgotPasswordRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [email]
|
required: [email]
|
||||||
@ -539,8 +567,9 @@ components:
|
|||||||
|
|
||||||
ResetPasswordRequest:
|
ResetPasswordRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [token, new_password]
|
required: [email, token, new_password]
|
||||||
properties:
|
properties:
|
||||||
|
email: { type: string, format: email }
|
||||||
token: { type: string }
|
token: { type: string }
|
||||||
new_password: { type: string }
|
new_password: { type: string }
|
||||||
|
|
||||||
@ -571,6 +600,13 @@ components:
|
|||||||
preferences: { type: object }
|
preferences: { type: object }
|
||||||
created_at: { type: string, format: date-time }
|
created_at: { type: string, format: date-time }
|
||||||
|
|
||||||
|
PendingSubscriptionResponse:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/Subscription'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
confirm_token: { type: string }
|
||||||
|
|
||||||
Tenant:
|
Tenant:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@ -6,11 +6,20 @@
|
|||||||
<ProjectReference Include="..\MemberCenter.Domain\MemberCenter.Domain.csproj" />
|
<ProjectReference Include="..\MemberCenter.Domain\MemberCenter.Domain.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
<ItemGroup>
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
||||||
|
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
|
|||||||
@ -1,2 +1,436 @@
|
|||||||
// See https://aka.ms/new-console-template for more information
|
using MemberCenter.Domain.Entities;
|
||||||
Console.WriteLine("Hello, World!");
|
using MemberCenter.Infrastructure.Identity;
|
||||||
|
using MemberCenter.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using System.CommandLine;
|
||||||
|
|
||||||
|
var root = new RootCommand("Member Center installer");
|
||||||
|
|
||||||
|
var connectionStringOption = new Option<string>(
|
||||||
|
name: "--connection-string",
|
||||||
|
description: "Database connection string");
|
||||||
|
connectionStringOption.AddAlias("-c");
|
||||||
|
|
||||||
|
var appsettingsOption = new Option<string?>(
|
||||||
|
name: "--appsettings",
|
||||||
|
description: "Path to appsettings.json");
|
||||||
|
|
||||||
|
var noPromptOption = new Option<bool>(
|
||||||
|
name: "--no-prompt",
|
||||||
|
description: "Disable interactive prompts");
|
||||||
|
|
||||||
|
var verboseOption = new Option<bool>(
|
||||||
|
name: "--verbose",
|
||||||
|
description: "Enable verbose output");
|
||||||
|
|
||||||
|
var forceOption = new Option<bool>(
|
||||||
|
name: "--force",
|
||||||
|
description: "Force execution even if already installed");
|
||||||
|
|
||||||
|
var adminEmailOption = new Option<string?>(
|
||||||
|
name: "--admin-email",
|
||||||
|
description: "Admin email");
|
||||||
|
|
||||||
|
var adminPasswordOption = new Option<string?>(
|
||||||
|
name: "--admin-password",
|
||||||
|
description: "Admin password");
|
||||||
|
|
||||||
|
var adminDisplayNameOption = new Option<string?>(
|
||||||
|
name: "--admin-display-name",
|
||||||
|
description: "Admin display name (optional)");
|
||||||
|
|
||||||
|
var targetMigrationOption = new Option<string?>(
|
||||||
|
name: "--target",
|
||||||
|
description: "Target migration");
|
||||||
|
|
||||||
|
var initCommand = new Command("init", "Initialize database (migrate + seed + admin)");
|
||||||
|
initCommand.AddOption(connectionStringOption);
|
||||||
|
initCommand.AddOption(appsettingsOption);
|
||||||
|
initCommand.AddOption(noPromptOption);
|
||||||
|
initCommand.AddOption(verboseOption);
|
||||||
|
initCommand.AddOption(forceOption);
|
||||||
|
initCommand.AddOption(adminEmailOption);
|
||||||
|
initCommand.AddOption(adminPasswordOption);
|
||||||
|
initCommand.AddOption(adminDisplayNameOption);
|
||||||
|
|
||||||
|
initCommand.SetHandler(async (string? connectionString, string? appsettings, bool noPrompt, bool verbose, bool force, string? adminEmail, string? adminPassword) =>
|
||||||
|
{
|
||||||
|
var resolvedConnection = ResolveConnectionString(connectionString, appsettings, noPrompt);
|
||||||
|
if (string.IsNullOrWhiteSpace(resolvedConnection))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("Connection string is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var services = BuildServices(resolvedConnection);
|
||||||
|
await using var scope = services.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<MemberCenterDbContext>();
|
||||||
|
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||||
|
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<ApplicationRole>>();
|
||||||
|
|
||||||
|
if (!force && await IsInstalledAsync(db))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("System already installed. Use --force to override.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!noPrompt)
|
||||||
|
{
|
||||||
|
adminEmail ??= Prompt("Admin email", "admin@example.com");
|
||||||
|
adminPassword ??= PromptSecret("Admin password");
|
||||||
|
}
|
||||||
|
|
||||||
|
adminEmail ??= "admin@example.com";
|
||||||
|
if (string.IsNullOrWhiteSpace(adminPassword))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("Admin password is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.Database.MigrateAsync();
|
||||||
|
|
||||||
|
await EnsureRoleAsync(roleManager, "admin");
|
||||||
|
await EnsureRoleAsync(roleManager, "support");
|
||||||
|
|
||||||
|
var admin = await userManager.FindByEmailAsync(adminEmail);
|
||||||
|
if (admin is null)
|
||||||
|
{
|
||||||
|
admin = new ApplicationUser
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserName = adminEmail,
|
||||||
|
Email = adminEmail,
|
||||||
|
EmailConfirmed = true
|
||||||
|
};
|
||||||
|
var createResult = await userManager.CreateAsync(admin, adminPassword);
|
||||||
|
if (!createResult.Succeeded)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(string.Join(Environment.NewLine, createResult.Errors.Select(e => e.Description)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await userManager.IsInRoleAsync(admin, "admin"))
|
||||||
|
{
|
||||||
|
await userManager.AddToRoleAsync(admin, "admin");
|
||||||
|
}
|
||||||
|
|
||||||
|
await SetInstalledFlagAsync(db);
|
||||||
|
|
||||||
|
if (verbose)
|
||||||
|
{
|
||||||
|
Console.WriteLine("Init completed.");
|
||||||
|
}
|
||||||
|
}, connectionStringOption, appsettingsOption, noPromptOption, verboseOption, forceOption, adminEmailOption, adminPasswordOption);
|
||||||
|
|
||||||
|
var addAdminCommand = new Command("add-admin", "Add admin user");
|
||||||
|
addAdminCommand.AddOption(connectionStringOption);
|
||||||
|
addAdminCommand.AddOption(appsettingsOption);
|
||||||
|
addAdminCommand.AddOption(noPromptOption);
|
||||||
|
addAdminCommand.AddOption(adminEmailOption);
|
||||||
|
addAdminCommand.AddOption(adminPasswordOption);
|
||||||
|
addAdminCommand.SetHandler(async (string? connectionString, string? appsettings, bool noPrompt, string? adminEmail, string? adminPassword) =>
|
||||||
|
{
|
||||||
|
var resolvedConnection = ResolveConnectionString(connectionString, appsettings, noPrompt);
|
||||||
|
if (string.IsNullOrWhiteSpace(resolvedConnection))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("Connection string is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!noPrompt)
|
||||||
|
{
|
||||||
|
adminEmail ??= Prompt("Admin email", "admin@example.com");
|
||||||
|
adminPassword ??= PromptSecret("Admin password");
|
||||||
|
}
|
||||||
|
|
||||||
|
adminEmail ??= "admin@example.com";
|
||||||
|
if (string.IsNullOrWhiteSpace(adminPassword))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("Admin password is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var services = BuildServices(resolvedConnection);
|
||||||
|
await using var scope = services.CreateAsyncScope();
|
||||||
|
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||||
|
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<ApplicationRole>>();
|
||||||
|
|
||||||
|
await EnsureRoleAsync(roleManager, "admin");
|
||||||
|
|
||||||
|
var admin = await userManager.FindByEmailAsync(adminEmail);
|
||||||
|
if (admin is null)
|
||||||
|
{
|
||||||
|
admin = new ApplicationUser
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserName = adminEmail,
|
||||||
|
Email = adminEmail,
|
||||||
|
EmailConfirmed = true
|
||||||
|
};
|
||||||
|
var createResult = await userManager.CreateAsync(admin, adminPassword);
|
||||||
|
if (!createResult.Succeeded)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(string.Join(Environment.NewLine, createResult.Errors.Select(e => e.Description)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await userManager.IsInRoleAsync(admin, "admin"))
|
||||||
|
{
|
||||||
|
await userManager.AddToRoleAsync(admin, "admin");
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine("Admin user ready.");
|
||||||
|
}, connectionStringOption, appsettingsOption, noPromptOption, adminEmailOption, adminPasswordOption);
|
||||||
|
|
||||||
|
var resetCommand = new Command("reset-admin-password", "Reset admin password");
|
||||||
|
resetCommand.AddOption(connectionStringOption);
|
||||||
|
resetCommand.AddOption(appsettingsOption);
|
||||||
|
resetCommand.AddOption(noPromptOption);
|
||||||
|
resetCommand.AddOption(adminEmailOption);
|
||||||
|
resetCommand.AddOption(adminPasswordOption);
|
||||||
|
resetCommand.SetHandler(async (string? connectionString, string? appsettings, bool noPrompt, string? adminEmail, string? adminPassword) =>
|
||||||
|
{
|
||||||
|
var resolvedConnection = ResolveConnectionString(connectionString, appsettings, noPrompt);
|
||||||
|
if (string.IsNullOrWhiteSpace(resolvedConnection))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("Connection string is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!noPrompt)
|
||||||
|
{
|
||||||
|
adminEmail ??= Prompt("Admin email", "admin@example.com");
|
||||||
|
adminPassword ??= PromptSecret("New password");
|
||||||
|
}
|
||||||
|
|
||||||
|
adminEmail ??= "admin@example.com";
|
||||||
|
if (string.IsNullOrWhiteSpace(adminPassword))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("Admin password is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var services = BuildServices(resolvedConnection);
|
||||||
|
await using var scope = services.CreateAsyncScope();
|
||||||
|
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||||
|
|
||||||
|
var admin = await userManager.FindByEmailAsync(adminEmail);
|
||||||
|
if (admin is null)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("Admin not found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var resetToken = await userManager.GeneratePasswordResetTokenAsync(admin);
|
||||||
|
var result = await userManager.ResetPasswordAsync(admin, resetToken, adminPassword);
|
||||||
|
if (!result.Succeeded)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(string.Join(Environment.NewLine, result.Errors.Select(e => e.Description)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine("Password reset completed.");
|
||||||
|
}, connectionStringOption, appsettingsOption, noPromptOption, adminEmailOption, adminPasswordOption);
|
||||||
|
|
||||||
|
var migrateCommand = new Command("migrate", "Run migrations only");
|
||||||
|
migrateCommand.AddOption(connectionStringOption);
|
||||||
|
migrateCommand.AddOption(appsettingsOption);
|
||||||
|
migrateCommand.AddOption(targetMigrationOption);
|
||||||
|
migrateCommand.SetHandler(async (string? connectionString, string? appsettings, string? target) =>
|
||||||
|
{
|
||||||
|
var resolvedConnection = ResolveConnectionString(connectionString, appsettings, noPrompt: true);
|
||||||
|
if (string.IsNullOrWhiteSpace(resolvedConnection))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("Connection string is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var services = BuildServices(resolvedConnection);
|
||||||
|
await using var scope = services.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<MemberCenterDbContext>();
|
||||||
|
if (string.IsNullOrWhiteSpace(target))
|
||||||
|
{
|
||||||
|
await db.Database.MigrateAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var migrator = scope.ServiceProvider.GetRequiredService<IMigrator>();
|
||||||
|
await migrator.MigrateAsync(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine("Migrations completed.");
|
||||||
|
}, connectionStringOption, appsettingsOption, targetMigrationOption);
|
||||||
|
|
||||||
|
root.AddCommand(initCommand);
|
||||||
|
root.AddCommand(addAdminCommand);
|
||||||
|
root.AddCommand(resetCommand);
|
||||||
|
root.AddCommand(migrateCommand);
|
||||||
|
|
||||||
|
return await root.InvokeAsync(args);
|
||||||
|
|
||||||
|
static IServiceProvider BuildServices(string connectionString)
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddDbContext<MemberCenterDbContext>(options =>
|
||||||
|
{
|
||||||
|
options.UseNpgsql(connectionString);
|
||||||
|
options.UseOpenIddict();
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddIdentity<ApplicationUser, ApplicationRole>(options =>
|
||||||
|
{
|
||||||
|
options.User.RequireUniqueEmail = true;
|
||||||
|
options.Password.RequireDigit = true;
|
||||||
|
options.Password.RequireLowercase = true;
|
||||||
|
options.Password.RequireUppercase = true;
|
||||||
|
options.Password.RequireNonAlphanumeric = false;
|
||||||
|
options.Password.RequiredLength = 8;
|
||||||
|
})
|
||||||
|
.AddEntityFrameworkStores<MemberCenterDbContext>()
|
||||||
|
.AddDefaultTokenProviders();
|
||||||
|
|
||||||
|
return services.BuildServiceProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
static string? ResolveConnectionString(string? connectionString, string? appsettingsPath, bool noPrompt)
|
||||||
|
{
|
||||||
|
var targetPath = string.IsNullOrWhiteSpace(appsettingsPath) ? "appsettings.json" : appsettingsPath;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(connectionString))
|
||||||
|
{
|
||||||
|
WriteConnectionString(targetPath, connectionString);
|
||||||
|
return connectionString;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File.Exists(targetPath))
|
||||||
|
{
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddJsonFile(targetPath)
|
||||||
|
.Build();
|
||||||
|
var existing = config.GetConnectionString("Default");
|
||||||
|
if (!string.IsNullOrWhiteSpace(existing))
|
||||||
|
{
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (noPrompt)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var host = Prompt("DB host", "localhost");
|
||||||
|
var database = Prompt("DB name", "member_center");
|
||||||
|
var username = Prompt("DB user", "postgres");
|
||||||
|
var password = PromptSecret("DB password");
|
||||||
|
var generated = $"Host={host};Database={database};Username={username};Password={password}";
|
||||||
|
WriteConnectionString(targetPath, generated);
|
||||||
|
return generated;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void WriteConnectionString(string path, string connectionString)
|
||||||
|
{
|
||||||
|
JsonNode root;
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(path);
|
||||||
|
root = JsonNode.Parse(json) ?? new JsonObject();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
root = new JsonObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
var obj = root as JsonObject ?? new JsonObject();
|
||||||
|
var connectionStrings = obj["ConnectionStrings"] as JsonObject ?? new JsonObject();
|
||||||
|
connectionStrings["Default"] = connectionString;
|
||||||
|
obj["ConnectionStrings"] = connectionStrings;
|
||||||
|
|
||||||
|
var options = new JsonSerializerOptions { WriteIndented = true };
|
||||||
|
File.WriteAllText(path, obj.ToJsonString(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<bool> IsInstalledAsync(MemberCenterDbContext db)
|
||||||
|
{
|
||||||
|
var flag = await db.SystemFlags.FirstOrDefaultAsync(f => f.Key == "installed");
|
||||||
|
return flag is not null && string.Equals(flag.Value, "true", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task SetInstalledFlagAsync(MemberCenterDbContext db)
|
||||||
|
{
|
||||||
|
var flag = await db.SystemFlags.FirstOrDefaultAsync(f => f.Key == "installed");
|
||||||
|
if (flag is null)
|
||||||
|
{
|
||||||
|
db.SystemFlags.Add(new SystemFlag
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Key = "installed",
|
||||||
|
Value = "true",
|
||||||
|
UpdatedAt = DateTimeOffset.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
flag.Value = "true";
|
||||||
|
flag.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task EnsureRoleAsync(RoleManager<ApplicationRole> roleManager, string roleName)
|
||||||
|
{
|
||||||
|
if (!await roleManager.RoleExistsAsync(roleName))
|
||||||
|
{
|
||||||
|
var result = await roleManager.CreateAsync(new ApplicationRole
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = roleName,
|
||||||
|
NormalizedName = roleName.ToUpperInvariant()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.Succeeded)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(string.Join(Environment.NewLine, result.Errors.Select(e => e.Description)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Prompt(string label, string defaultValue)
|
||||||
|
{
|
||||||
|
Console.Write($"{label} [{defaultValue}]: ");
|
||||||
|
var input = Console.ReadLine();
|
||||||
|
return string.IsNullOrWhiteSpace(input) ? defaultValue : input.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
static string PromptSecret(string label)
|
||||||
|
{
|
||||||
|
Console.Write($"{label}: ");
|
||||||
|
var password = string.Empty;
|
||||||
|
ConsoleKeyInfo key;
|
||||||
|
while ((key = Console.ReadKey(true)).Key != ConsoleKey.Enter)
|
||||||
|
{
|
||||||
|
if (key.Key == ConsoleKey.Backspace && password.Length > 0)
|
||||||
|
{
|
||||||
|
password = password[..^1];
|
||||||
|
Console.Write("\b \b");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!char.IsControl(key.KeyChar))
|
||||||
|
{
|
||||||
|
password += key.KeyChar;
|
||||||
|
Console.Write("*");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Console.WriteLine();
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user