Line data Source code
1 : /* SPDX-License-Identifier: GPL-3.0-or-later */
2 : /* Copyright 2026 Peter Csaszar */
3 :
4 : /**
5 : * @file app/config_wizard.c
6 : * @brief First-run interactive config wizard (FEAT-37).
7 : *
8 : * Shared by tg-cli, tg-cli-ro, and tg-tui via `<binary> login`.
9 : * Interactive path: prompts the user for api_id and api_hash on a TTY,
10 : * suppressing echo for the hash.
11 : * Batch path: validates the values from argv without any prompts.
12 : * On success writes ~/.config/tg-cli/config.ini atomically (tmp+rename+fsync)
13 : * and chmods it 0600.
14 : */
15 :
16 : #include "app/config_wizard.h"
17 :
18 : #include "fs_util.h"
19 : #include "platform/path.h"
20 : #include "platform/terminal.h"
21 :
22 : #include <ctype.h>
23 : #include <errno.h>
24 : #include <fcntl.h>
25 : #include <stdio.h>
26 : #include <stdlib.h>
27 : #include <string.h>
28 : #include <sys/stat.h>
29 : #include <unistd.h>
30 :
31 : #define API_HASH_LEN 32
32 : #define MAX_RETRIES 3
33 :
34 : /* ---- Validation helpers ---- */
35 :
36 : /**
37 : * @brief Parse @p s as a positive 32-bit decimal integer.
38 : * @return The parsed value, or -1 on error.
39 : */
40 9 : static int parse_api_id(const char *s) {
41 9 : if (!s || !*s) return -1;
42 9 : char *end = NULL;
43 9 : long v = strtol(s, &end, 10);
44 9 : if (!end || *end != '\0') return -1;
45 8 : if (v <= 0 || v > 0x7FFFFFFF) return -1;
46 6 : return (int)v;
47 : }
48 :
49 : /**
50 : * @brief Validate @p s as a 32-char lowercase hex string.
51 : * @return 0 if valid, -1 otherwise.
52 : */
53 6 : static int validate_api_hash(const char *s) {
54 6 : if (!s) return -1;
55 6 : size_t n = strlen(s);
56 6 : if (n != API_HASH_LEN) return -1;
57 101 : for (size_t i = 0; i < n; i++) {
58 98 : unsigned char c = (unsigned char)s[i];
59 98 : if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')))
60 2 : return -1;
61 : }
62 3 : return 0;
63 : }
64 :
65 : /* ---- Config file write ---- */
66 :
67 : /**
68 : * @brief Determine the config directory path (without creating it).
69 : *
70 : * Result is written into @p buf (length @p cap).
71 : * @return 0 on success, -1 if the platform directory is unknown.
72 : */
73 3 : static int get_config_dir(char *buf, size_t cap) {
74 3 : const char *base = platform_config_dir();
75 3 : if (!base) return -1;
76 3 : snprintf(buf, cap, "%s/tg-cli", base);
77 3 : return 0;
78 : }
79 :
80 : /**
81 : * @brief Check whether @p path is a non-empty regular file.
82 : * @return 1 if the file exists and has size > 0, 0 otherwise.
83 : */
84 2 : static int file_nonempty(const char *path) {
85 : struct stat st;
86 2 : if (stat(path, &st) != 0) return 0;
87 1 : return (S_ISREG(st.st_mode) && st.st_size > 0) ? 1 : 0;
88 : }
89 :
90 : /**
91 : * @brief Atomically write api_id + api_hash to config.ini.
92 : *
93 : * Writes to a temp file, fsyncs, then renames over the destination.
94 : * Sets the final file to mode 0600.
95 : *
96 : * @return 0 on success, -1 on any I/O error.
97 : */
98 2 : static int write_config(const char *dir, int api_id, const char *api_hash) {
99 : char path[1024];
100 : char tmp_path[1040];
101 2 : snprintf(path, sizeof(path), "%s/config.ini", dir);
102 2 : snprintf(tmp_path, sizeof(tmp_path), "%s/config.ini.tmp", dir);
103 :
104 : /* Create the directory if needed. */
105 2 : if (fs_mkdir_p(dir, 0700) != 0) {
106 0 : fprintf(stderr, "login: cannot create config dir %s: %s\n",
107 0 : dir, strerror(errno));
108 0 : return -1;
109 : }
110 :
111 : /* Write temp file. */
112 2 : int fd = open(tmp_path, O_WRONLY | O_CREAT | O_TRUNC, 0600);
113 2 : if (fd < 0) {
114 0 : fprintf(stderr, "login: cannot open %s: %s\n", tmp_path, strerror(errno));
115 0 : return -1;
116 : }
117 :
118 2 : FILE *fp = fdopen(fd, "w");
119 2 : if (!fp) {
120 0 : fprintf(stderr, "login: fdopen failed: %s\n", strerror(errno));
121 0 : close(fd);
122 0 : return -1;
123 : }
124 :
125 2 : fprintf(fp, "# tg-cli configuration\n");
126 2 : fprintf(fp, "# Generated by `login` wizard. Do NOT share this file.\n");
127 2 : fprintf(fp, "api_id=%d\n", api_id);
128 2 : fprintf(fp, "api_hash=%s\n", api_hash);
129 :
130 2 : if (fflush(fp) != 0) {
131 0 : fprintf(stderr, "login: fflush failed: %s\n", strerror(errno));
132 0 : fclose(fp);
133 0 : return -1;
134 : }
135 2 : if (fsync(fileno(fp)) != 0) {
136 0 : fprintf(stderr, "login: fsync failed: %s\n", strerror(errno));
137 0 : fclose(fp);
138 0 : return -1;
139 : }
140 2 : fclose(fp);
141 :
142 : /* Atomic rename over destination. */
143 2 : if (rename(tmp_path, path) != 0) {
144 0 : fprintf(stderr, "login: rename failed: %s\n", strerror(errno));
145 0 : return -1;
146 : }
147 :
148 : /* Ensure correct permissions even if the file existed before. */
149 2 : chmod(path, 0600);
150 :
151 2 : return 0;
152 : }
153 :
154 : /* ---- Interactive wizard ---- */
155 :
156 1 : int config_wizard_run_interactive(void) {
157 1 : if (!isatty(STDIN_FILENO)) {
158 1 : fprintf(stderr,
159 : "login: stdin is not a TTY — use batch mode:\n"
160 : " <binary> login --api-id N --api-hash HEX\n");
161 1 : return -1;
162 : }
163 :
164 : /* Determine and check config path upfront. */
165 : char cfg_dir[1024];
166 0 : if (get_config_dir(cfg_dir, sizeof(cfg_dir)) != 0) {
167 0 : fprintf(stderr, "login: cannot determine config directory\n");
168 0 : return -1;
169 : }
170 : char cfg_path[1040];
171 0 : snprintf(cfg_path, sizeof(cfg_path), "%s/config.ini", cfg_dir);
172 :
173 0 : if (file_nonempty(cfg_path)) {
174 0 : fprintf(stderr,
175 : "login: %s already exists and is non-empty.\n"
176 : " Pass --force to overwrite, or edit it manually.\n",
177 : cfg_path);
178 0 : return -1;
179 : }
180 :
181 0 : printf(
182 : "\nWelcome to tg-cli! This one-time setup records your Telegram API\n"
183 : "credentials into %s (mode 0600).\n"
184 : "\n"
185 : "You need an api_id and api_hash from https://my.telegram.org\n"
186 : "For a step-by-step guide see: docs/user/setup-my-telegram-org.md\n"
187 : "\n"
188 : " 1. Log in with your phone number and the code Telegram sends.\n"
189 : " 2. Go to 'API development tools'.\n"
190 : " 3. Fill out the 'Create new application' form:\n"
191 : " - App title: anything, e.g. \"my tg-cli\"\n"
192 : " - Short name: any 5+ char slug\n"
193 : " - Platform: Desktop\n"
194 : " - Description: optional\n"
195 : " 4. Copy the numeric 'App api_id' and the 32-char 'App api_hash'.\n"
196 : " 5. Paste them below.\n"
197 : "\n"
198 : "Press Ctrl-C to abort at any time.\n"
199 : "\n",
200 : cfg_path
201 : );
202 :
203 : /* Prompt for api_id with retries. */
204 0 : int api_id = -1;
205 0 : for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
206 : char buf[64];
207 0 : printf("Enter your api_id: ");
208 0 : fflush(stdout);
209 0 : if (!fgets(buf, sizeof(buf), stdin)) {
210 0 : fprintf(stderr, "\nlogin: aborted\n");
211 0 : return -1;
212 : }
213 : /* Strip trailing newline. */
214 0 : size_t n = strlen(buf);
215 0 : while (n > 0 && (buf[n-1] == '\n' || buf[n-1] == '\r')) buf[--n] = '\0';
216 :
217 0 : api_id = parse_api_id(buf);
218 0 : if (api_id > 0) break;
219 :
220 0 : fprintf(stderr,
221 : " Error: api_id must be a positive integer (e.g. 12345).\n");
222 0 : if (attempt == MAX_RETRIES - 1) {
223 0 : fprintf(stderr, "login: too many invalid attempts\n");
224 0 : return -1;
225 : }
226 : }
227 :
228 : /* Prompt for api_hash with echo suppressed and retries. */
229 : char api_hash[API_HASH_LEN + 4]; /* +4 for NUL + possible \r\n */
230 0 : memset(api_hash, 0, sizeof(api_hash));
231 0 : int hash_ok = 0;
232 0 : for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
233 0 : int n = terminal_read_password("Enter your api_hash: ",
234 : api_hash, sizeof(api_hash));
235 0 : if (n < 0) {
236 0 : fprintf(stderr, "\nlogin: aborted\n");
237 0 : return -1;
238 : }
239 : /* terminal_read_password already NUL-terminates; strip any trailing
240 : * newline that some implementations might leave. */
241 0 : size_t slen = strlen(api_hash);
242 0 : while (slen > 0 && (api_hash[slen-1] == '\n' || api_hash[slen-1] == '\r'))
243 0 : api_hash[--slen] = '\0';
244 :
245 0 : if (validate_api_hash(api_hash) == 0) {
246 0 : hash_ok = 1;
247 0 : break;
248 : }
249 0 : fprintf(stderr,
250 : " Error: api_hash must be exactly 32 lowercase hex "
251 : "characters ([0-9a-f]).\n");
252 0 : if (attempt == MAX_RETRIES - 1) {
253 0 : fprintf(stderr, "login: too many invalid attempts\n");
254 0 : return -1;
255 : }
256 : }
257 0 : if (!hash_ok) return -1;
258 :
259 0 : printf("Verifying... OK (format valid)\n");
260 :
261 0 : if (write_config(cfg_dir, api_id, api_hash) != 0) return -1;
262 :
263 0 : printf("Saved to %s (mode 0600).\n", cfg_path);
264 0 : printf("Next: run the binary and enter your phone number to complete login.\n\n");
265 0 : return 0;
266 : }
267 :
268 : /* ---- Batch wizard ---- */
269 :
270 11 : int config_wizard_run_batch(const char *api_id_str, const char *api_hash_str,
271 : int force) {
272 11 : if (!api_id_str || !api_hash_str) {
273 2 : fprintf(stderr,
274 : "login: --api-id and --api-hash are both required in batch mode\n");
275 2 : return -1;
276 : }
277 :
278 9 : int api_id = parse_api_id(api_id_str);
279 9 : if (api_id <= 0) {
280 3 : fprintf(stderr,
281 : "login: --api-id must be a positive integer, got: %s\n",
282 : api_id_str);
283 3 : return -1;
284 : }
285 :
286 6 : if (validate_api_hash(api_hash_str) != 0) {
287 3 : fprintf(stderr,
288 : "login: --api-hash must be exactly 32 lowercase hex chars "
289 : "([0-9a-f]), got: %s\n",
290 : api_hash_str);
291 3 : return -1;
292 : }
293 :
294 : char cfg_dir[1024];
295 3 : if (get_config_dir(cfg_dir, sizeof(cfg_dir)) != 0) {
296 0 : fprintf(stderr, "login: cannot determine config directory\n");
297 0 : return -1;
298 : }
299 :
300 : char cfg_path[1040];
301 3 : snprintf(cfg_path, sizeof(cfg_path), "%s/config.ini", cfg_dir);
302 :
303 3 : if (!force && file_nonempty(cfg_path)) {
304 1 : fprintf(stderr,
305 : "login: %s already exists and is non-empty.\n"
306 : " Pass --force to overwrite.\n",
307 : cfg_path);
308 1 : return -1;
309 : }
310 :
311 2 : if (write_config(cfg_dir, api_id, api_hash_str) != 0) return -1;
312 :
313 2 : printf("Saved to %s (mode 0600).\n", cfg_path);
314 2 : return 0;
315 : }
|