- Add upsert pattern to SecretsScanService to prevent duplicate key errors on repeated scans - Auto-test connection health on startup so status reflects reality - Fix Actions view to read identity from appstate instead of broken localStorage hack - Fetch both project and group secrets in parallel, add "All Scopes" filter to Secrets view - Enable noCache on UtilityWebsiteServer to prevent stale browser cache
207 lines
7.6 KiB
TypeScript
207 lines
7.6 KiB
TypeScript
import * as plugins from '../plugins.ts';
|
|
import { logger } from '../logging.ts';
|
|
import type * as interfaces from '../../ts_interfaces/index.ts';
|
|
import { BaseProvider, GiteaProvider, GitLabProvider } from '../providers/index.ts';
|
|
import type { StorageManager } from '../storage/index.ts';
|
|
|
|
const LEGACY_CONNECTIONS_FILE = './.nogit/connections.json';
|
|
const CONNECTIONS_PREFIX = '/connections/';
|
|
const KEYCHAIN_PREFIX = 'keychain:';
|
|
|
|
/**
|
|
* Manages provider connections — persists each connection as an
|
|
* individual JSON file via StorageManager. Tokens are stored in
|
|
* the OS keychain (or encrypted file fallback) via SmartSecret.
|
|
*/
|
|
export class ConnectionManager {
|
|
private connections: interfaces.data.IProviderConnection[] = [];
|
|
private storageManager: StorageManager;
|
|
private smartSecret: plugins.smartsecret.SmartSecret;
|
|
/** Resolves when background connection health checks complete */
|
|
public healthCheckDone: Promise<void> = Promise.resolve();
|
|
|
|
constructor(storageManager: StorageManager, smartSecret: plugins.smartsecret.SmartSecret) {
|
|
this.storageManager = storageManager;
|
|
this.smartSecret = smartSecret;
|
|
}
|
|
|
|
async init(): Promise<void> {
|
|
await this.migrateLegacyFile();
|
|
await this.loadConnections();
|
|
// Auto-test all connections in the background
|
|
this.healthCheckDone = this.testAllConnections();
|
|
}
|
|
|
|
/**
|
|
* Tests all loaded connections in the background and updates their status.
|
|
* Fire-and-forget — does not block startup.
|
|
*/
|
|
private async testAllConnections(): Promise<void> {
|
|
for (const conn of this.connections) {
|
|
try {
|
|
const provider = this.getProvider(conn.id);
|
|
const result = await provider.testConnection();
|
|
conn.status = result.ok ? 'connected' : 'error';
|
|
await this.persistConnection(conn);
|
|
} catch {
|
|
conn.status = 'error';
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* One-time migration from the legacy .nogit/connections.json file.
|
|
*/
|
|
private async migrateLegacyFile(): Promise<void> {
|
|
try {
|
|
const text = await Deno.readTextFile(LEGACY_CONNECTIONS_FILE);
|
|
const legacy: interfaces.data.IProviderConnection[] = JSON.parse(text);
|
|
if (legacy.length > 0) {
|
|
logger.info(`Migrating ${legacy.length} connection(s) from legacy file...`);
|
|
for (const conn of legacy) {
|
|
await this.storageManager.setJSON(`${CONNECTIONS_PREFIX}${conn.id}.json`, conn);
|
|
}
|
|
// Rename legacy file so migration doesn't repeat
|
|
await Deno.rename(LEGACY_CONNECTIONS_FILE, LEGACY_CONNECTIONS_FILE + '.migrated');
|
|
logger.success('Legacy connections migrated successfully');
|
|
}
|
|
} catch {
|
|
// No legacy file or already migrated — nothing to do
|
|
}
|
|
}
|
|
|
|
private async loadConnections(): Promise<void> {
|
|
const keys = await this.storageManager.list(CONNECTIONS_PREFIX);
|
|
this.connections = [];
|
|
for (const key of keys) {
|
|
const conn = await this.storageManager.getJSON<interfaces.data.IProviderConnection>(key);
|
|
if (conn) {
|
|
if (conn.token.startsWith(KEYCHAIN_PREFIX)) {
|
|
// Token is in keychain — retrieve it
|
|
const realToken = await this.smartSecret.getSecret(conn.id);
|
|
if (realToken) {
|
|
conn.token = realToken;
|
|
} else {
|
|
logger.warn(`Could not retrieve token for connection ${conn.id} from keychain`);
|
|
}
|
|
} else if (conn.token && conn.token !== '***') {
|
|
// Plaintext token found — auto-migrate to keychain
|
|
await this.migrateTokenToKeychain(conn);
|
|
}
|
|
this.connections.push(conn);
|
|
}
|
|
}
|
|
if (this.connections.length > 0) {
|
|
logger.info(`Loaded ${this.connections.length} connection(s)`);
|
|
} else {
|
|
logger.debug('No existing connections found, starting fresh');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Migrates a plaintext token to keychain storage.
|
|
*/
|
|
private async migrateTokenToKeychain(
|
|
conn: interfaces.data.IProviderConnection,
|
|
): Promise<void> {
|
|
try {
|
|
await this.smartSecret.setSecret(conn.id, conn.token);
|
|
// Save sentinel to JSON file
|
|
const jsonConn = { ...conn, token: `${KEYCHAIN_PREFIX}${conn.id}` };
|
|
await this.storageManager.setJSON(`${CONNECTIONS_PREFIX}${conn.id}.json`, jsonConn);
|
|
logger.info(`Migrated token for connection "${conn.name}" to keychain`);
|
|
} catch (err) {
|
|
logger.warn(`Failed to migrate token for ${conn.id} to keychain: ${err}`);
|
|
}
|
|
}
|
|
|
|
private async persistConnection(conn: interfaces.data.IProviderConnection): Promise<void> {
|
|
// Store real token in keychain
|
|
await this.smartSecret.setSecret(conn.id, conn.token);
|
|
// Save JSON with sentinel value
|
|
const jsonConn = { ...conn, token: `${KEYCHAIN_PREFIX}${conn.id}` };
|
|
await this.storageManager.setJSON(`${CONNECTIONS_PREFIX}${conn.id}.json`, jsonConn);
|
|
}
|
|
|
|
private async removeConnection(id: string): Promise<void> {
|
|
await this.smartSecret.deleteSecret(id);
|
|
await this.storageManager.delete(`${CONNECTIONS_PREFIX}${id}.json`);
|
|
}
|
|
|
|
getConnections(): interfaces.data.IProviderConnection[] {
|
|
return this.connections.map((c) => ({ ...c, token: '***' }));
|
|
}
|
|
|
|
getConnection(id: string): interfaces.data.IProviderConnection | undefined {
|
|
return this.connections.find((c) => c.id === id);
|
|
}
|
|
|
|
async createConnection(
|
|
name: string,
|
|
providerType: interfaces.data.TProviderType,
|
|
baseUrl: string,
|
|
token: string,
|
|
): Promise<interfaces.data.IProviderConnection> {
|
|
const connection: interfaces.data.IProviderConnection = {
|
|
id: crypto.randomUUID(),
|
|
name,
|
|
providerType,
|
|
baseUrl: baseUrl.replace(/\/+$/, ''),
|
|
token,
|
|
createdAt: Date.now(),
|
|
status: 'disconnected',
|
|
};
|
|
this.connections.push(connection);
|
|
await this.persistConnection(connection);
|
|
logger.success(`Connection created: ${name} (${providerType})`);
|
|
return { ...connection, token: '***' };
|
|
}
|
|
|
|
async updateConnection(
|
|
id: string,
|
|
updates: { name?: string; baseUrl?: string; token?: string },
|
|
): Promise<interfaces.data.IProviderConnection> {
|
|
const conn = this.connections.find((c) => c.id === id);
|
|
if (!conn) throw new Error(`Connection not found: ${id}`);
|
|
if (updates.name) conn.name = updates.name;
|
|
if (updates.baseUrl) conn.baseUrl = updates.baseUrl.replace(/\/+$/, '');
|
|
if (updates.token) conn.token = updates.token;
|
|
await this.persistConnection(conn);
|
|
return { ...conn, token: '***' };
|
|
}
|
|
|
|
async deleteConnection(id: string): Promise<void> {
|
|
const idx = this.connections.findIndex((c) => c.id === id);
|
|
if (idx === -1) throw new Error(`Connection not found: ${id}`);
|
|
this.connections.splice(idx, 1);
|
|
await this.removeConnection(id);
|
|
logger.info(`Connection deleted: ${id}`);
|
|
}
|
|
|
|
async testConnection(id: string): Promise<{ ok: boolean; error?: string }> {
|
|
const provider = this.getProvider(id);
|
|
const result = await provider.testConnection();
|
|
const conn = this.connections.find((c) => c.id === id)!;
|
|
conn.status = result.ok ? 'connected' : 'error';
|
|
await this.persistConnection(conn);
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Factory: returns the correct provider instance for a connection ID
|
|
*/
|
|
getProvider(connectionId: string): BaseProvider {
|
|
const conn = this.connections.find((c) => c.id === connectionId);
|
|
if (!conn) throw new Error(`Connection not found: ${connectionId}`);
|
|
|
|
switch (conn.providerType) {
|
|
case 'gitea':
|
|
return new GiteaProvider(conn.id, conn.baseUrl, conn.token);
|
|
case 'gitlab':
|
|
return new GitLabProvider(conn.id, conn.baseUrl, conn.token);
|
|
default:
|
|
throw new Error(`Unknown provider type: ${conn.providerType}`);
|
|
}
|
|
}
|
|
}
|