File size: 5,506 Bytes
57a889c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
import { db } from '../db/database';
import { decrypt_api_key, maybe_encrypt_api_key } from './apiKeyCrypto';

const ENCRYPTED_SETTING_KEYS = new Set(['webhook_url', 'ntfy_token', 'mapbox_access_token']);
// Encrypted keys that are masked (••••••••) when returned to the client.
// Keys not in this set but in ENCRYPTED_SETTING_KEYS are decrypted and returned.
const MASKED_SETTING_KEYS = new Set(['webhook_url', 'ntfy_token']);

export const DEFAULTABLE_USER_SETTING_KEYS = [
  'temperature_unit',
  'dark_mode',
  'time_format',
  'blur_booking_codes',
  'map_tile_url',
  // Instance-wide Mapbox defaults: an admin can set a shared token + style so the
  // whole instance uses Mapbox without each user pasting their own key (#920).
  'map_provider',
  'mapbox_access_token',
  'mapbox_style',
  'mapbox_3d_enabled',
  'mapbox_quality_mode',
] as const;

type DefaultableKey = typeof DEFAULTABLE_USER_SETTING_KEYS[number];

const VALID_VALUES: Partial<Record<DefaultableKey, unknown[]>> = {
  temperature_unit: ['fahrenheit', 'celsius'],
  time_format: ['12h', '24h'],
  dark_mode: [true, false, 'light', 'dark', 'auto'],
  map_provider: ['leaflet', 'mapbox-gl'],
};

const BOOLEAN_KEYS = new Set<DefaultableKey>(['blur_booking_codes', 'mapbox_3d_enabled', 'mapbox_quality_mode']);

function parseValue(raw: string): unknown {
  try { return JSON.parse(raw); } catch { return raw; }
}

export function getAdminUserDefaults(): Record<string, unknown> {
  const rows = db.prepare(
    "SELECT key, value FROM app_settings WHERE key LIKE 'default_user_setting_%'"
  ).all() as { key: string; value: string }[];
  const defaults: Record<string, unknown> = {};
  for (const row of rows) {
    const settingKey = row.key.slice('default_user_setting_'.length);
    if (ENCRYPTED_SETTING_KEYS.has(settingKey)) {
      defaults[settingKey] = row.value ? (decrypt_api_key(row.value) ?? '') : '';
    } else {
      defaults[settingKey] = parseValue(row.value);
    }
  }
  return defaults;
}

export function setAdminUserDefaults(partial: Record<string, unknown>): void {
  const upsert = db.prepare(
    `INSERT INTO app_settings (key, value) VALUES (?, ?)
     ON CONFLICT(key) DO UPDATE SET value = excluded.value`
  );
  const del = db.prepare("DELETE FROM app_settings WHERE key = ?");

  db.exec('BEGIN');
  try {
    for (const [key, value] of Object.entries(partial)) {
      if (!(DEFAULTABLE_USER_SETTING_KEYS as readonly string[]).includes(key)) {
        throw new Error(`Invalid setting key: ${key}`);
      }
      const typedKey = key as DefaultableKey;
      const appKey = `default_user_setting_${key}`;

      // null/undefined means "reset to built-in default" — delete the row
      if (value === null || value === undefined) {
        del.run(appKey);
        continue;
      }

      if (BOOLEAN_KEYS.has(typedKey) && typeof value !== 'boolean') {
        throw new Error(`Setting ${key} must be a boolean`);
      }
      const allowed = VALID_VALUES[typedKey];
      if (allowed && !allowed.includes(value)) {
        throw new Error(`Invalid value for ${key}: ${value}`);
      }

      // Encrypt sensitive defaults (the shared Mapbox token) at rest, like the
      // per-user equivalents; everything else is stored as plain JSON.
      const stored = ENCRYPTED_SETTING_KEYS.has(key)
        ? (maybe_encrypt_api_key(String(value)) ?? String(value))
        : JSON.stringify(value);
      upsert.run(appKey, stored);
    }
    db.exec('COMMIT');
  } catch (err) {
    db.exec('ROLLBACK');
    throw err;
  }
}

export function getUserSettings(userId: number): Record<string, unknown> {
  const adminDefaults = getAdminUserDefaults();

  const rows = db.prepare('SELECT key, value FROM settings WHERE user_id = ?').all(userId) as { key: string; value: string }[];
  const userSettings: Record<string, unknown> = {};
  for (const row of rows) {
    if (MASKED_SETTING_KEYS.has(row.key)) {
      userSettings[row.key] = row.value ? '••••••••' : '';
      continue;
    }
    if (ENCRYPTED_SETTING_KEYS.has(row.key)) {
      userSettings[row.key] = row.value ? (decrypt_api_key(row.value) ?? '') : '';
      continue;
    }
    try {
      userSettings[row.key] = JSON.parse(row.value);
    } catch {
      userSettings[row.key] = row.value;
    }
  }

  // Admin defaults fill in only for keys the user hasn't explicitly set
  return { ...adminDefaults, ...userSettings };
}

function serializeValue(key: string, value: unknown): string {
  const raw = typeof value === 'object' ? JSON.stringify(value) : String(value !== undefined ? value : '');
  if (ENCRYPTED_SETTING_KEYS.has(key)) return maybe_encrypt_api_key(raw) ?? raw;
  return raw;
}

export function upsertSetting(userId: number, key: string, value: unknown) {
  db.prepare(`
    INSERT INTO settings (user_id, key, value) VALUES (?, ?, ?)
    ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value
  `).run(userId, key, serializeValue(key, value));
}

export function bulkUpsertSettings(userId: number, settings: Record<string, unknown>) {
  const upsert = db.prepare(`
    INSERT INTO settings (user_id, key, value) VALUES (?, ?, ?)
    ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value
  `);
  db.exec('BEGIN');
  try {
    for (const [key, value] of Object.entries(settings)) {
      upsert.run(userId, key, serializeValue(key, value));
    }
    db.exec('COMMIT');
  } catch (err) {
    db.exec('ROLLBACK');
    throw err;
  }
  return Object.keys(settings).length;
}