Fossil SCM

fossil-scm / src / user.c
Source Blame History 835 lines
dbda8d6… drh 1 /*
c19f34c… drh 2 ** Copyright (c) 2006 D. Richard Hipp
dbda8d6… drh 3 **
dbda8d6… drh 4 ** This program is free software; you can redistribute it and/or
c06edd2… drh 5 ** modify it under the terms of the Simplified BSD License (also
c06edd2… drh 6 ** known as the "2-Clause License" or "FreeBSD License".)
c06edd2… drh 7
dbda8d6… drh 8 ** This program is distributed in the hope that it will be useful,
c06edd2… drh 9 ** but without any warranty; without even the implied warranty of
c06edd2… drh 10 ** merchantability or fitness for a particular purpose.
dbda8d6… drh 11 **
dbda8d6… drh 12 ** Author contact information:
dbda8d6… drh 13 ** [email protected]
dbda8d6… drh 14 ** http://www.hwaci.com/drh/
dbda8d6… drh 15 **
dbda8d6… drh 16 *******************************************************************************
dbda8d6… drh 17 **
dbda8d6… drh 18 ** Commands and procedures used for creating, processing, editing, and
dbda8d6… drh 19 ** querying information about users.
dbda8d6… drh 20 */
dbda8d6… drh 21 #include "config.h"
dbda8d6… drh 22 #include "user.h"
dbda8d6… drh 23
dbda8d6… drh 24 /*
dbda8d6… drh 25 ** Strip leading and trailing space from a string and add the string
dbda8d6… drh 26 ** onto the end of a blob.
dbda8d6… drh 27 */
dbda8d6… drh 28 static void strip_string(Blob *pBlob, char *z){
dbda8d6… drh 29 int i;
dbda8d6… drh 30 blob_reset(pBlob);
2fac809… drh 31 while( fossil_isspace(*z) ){ z++; }
dbda8d6… drh 32 for(i=0; z[i]; i++){
dbda8d6… drh 33 if( z[i]=='\r' || z[i]=='\n' ){
2fac809… drh 34 while( i>0 && fossil_isspace(z[i-1]) ){ i--; }
dbda8d6… drh 35 z[i] = 0;
dbda8d6… drh 36 break;
dbda8d6… drh 37 }
c426475… jan.nijtmans 38 if( z[i]>0 && z[i]<' ' ) z[i] = ' ';
dbda8d6… drh 39 }
dbda8d6… drh 40 blob_append(pBlob, z, -1);
dbda8d6… drh 41 }
dbda8d6… drh 42
999e33c… mistachkin 43 #if defined(_WIN32) || (defined(__BIONIC__) && !defined(FOSSIL_HAVE_GETPASS))
a8484dc… mistachkin 44 #ifdef _WIN32
3c326ea… ron 45 #include <conio.h>
3c326ea… ron 46 #endif
a8484dc… mistachkin 47
d0305b3… aku 48 /*
a8484dc… mistachkin 49 ** getpass() for Windows and Android.
d0305b3… aku 50 */
a8484dc… mistachkin 51 static char *zPwdBuffer = 0;
a8484dc… mistachkin 52 static size_t nPwdBuffer = 0;
a8484dc… mistachkin 53
d0305b3… aku 54 static char *getpass(const char *prompt){
a8484dc… mistachkin 55 char *zPwd;
a8484dc… mistachkin 56 size_t nPwd;
d0305b3… aku 57 size_t i;
513dd00… drh 58 #if defined(_WIN32)
513dd00… drh 59 int useGetch = _isatty(_fileno(stderr));
513dd00… drh 60 #endif
d0305b3… aku 61
a8484dc… mistachkin 62 if( zPwdBuffer==0 ){
a8484dc… mistachkin 63 zPwdBuffer = fossil_secure_alloc_page(&nPwdBuffer);
a8484dc… mistachkin 64 assert( zPwdBuffer );
a8484dc… mistachkin 65 }else{
a8484dc… mistachkin 66 fossil_secure_zero(zPwdBuffer, nPwdBuffer);
a8484dc… mistachkin 67 }
a8484dc… mistachkin 68 zPwd = zPwdBuffer;
a8484dc… mistachkin 69 nPwd = nPwdBuffer;
d0305b3… aku 70 fputs(prompt,stderr);
d0305b3… aku 71 fflush(stderr);
a8484dc… mistachkin 72 assert( zPwd!=0 );
a8484dc… mistachkin 73 assert( nPwd>0 );
a8484dc… mistachkin 74 for(i=0; i<nPwd-1; ++i){
d4d66d1… jan.nijtmans 75 #if defined(_WIN32)
513dd00… drh 76 zPwd[i] = useGetch ? _getch() : getc(stdin);
d4d66d1… jan.nijtmans 77 #else
a8484dc… mistachkin 78 zPwd[i] = getc(stdin);
d4d66d1… jan.nijtmans 79 #endif
a8484dc… mistachkin 80 if(zPwd[i]=='\r' || zPwd[i]=='\n'){
d0305b3… aku 81 break;
d0305b3… aku 82 }
d0305b3… aku 83 /* BS or DEL */
a8484dc… mistachkin 84 else if(i>0 && (zPwd[i]==8 || zPwd[i]==127)){
d0305b3… aku 85 i -= 2;
d0305b3… aku 86 continue;
d0305b3… aku 87 }
d0305b3… aku 88 /* CTRL-C */
a8484dc… mistachkin 89 else if(zPwd[i]==3) {
d0305b3… aku 90 i=0;
d0305b3… aku 91 break;
d0305b3… aku 92 }
d0305b3… aku 93 /* ESC */
a8484dc… mistachkin 94 else if(zPwd[i]==27){
d0305b3… aku 95 i=0;
d0305b3… aku 96 break;
d0305b3… aku 97 }
d0305b3… aku 98 else{
513dd00… drh 99 #if defined(_WIN32)
513dd00… drh 100 if( useGetch )
513dd00… drh 101 #endif
d0305b3… aku 102 fputc('*',stderr);
d0305b3… aku 103 }
d0305b3… aku 104 }
a8484dc… mistachkin 105 zPwd[i]='\0';
d0305b3… aku 106 fputs("\n", stderr);
a8484dc… mistachkin 107 assert( zPwd==zPwdBuffer );
a8484dc… mistachkin 108 return zPwd;
a8484dc… mistachkin 109 }
a8484dc… mistachkin 110 void freepass(){
a8484dc… mistachkin 111 if( !zPwdBuffer ) return;
a8484dc… mistachkin 112 assert( nPwdBuffer>0 );
a8484dc… mistachkin 113 fossil_secure_free_page(zPwdBuffer, nPwdBuffer);
1c9d5cd… stephan 114 zPwdBuffer = 0;
1c9d5cd… stephan 115 nPwdBuffer = 0;
f3acbe4… drh 116 }
d0305b3… aku 117 #endif
55f3f3d… drh 118
e1034c4… drh 119 /*
e1034c4… drh 120 ** Scramble substitution matrix:
e1034c4… drh 121 */
e1034c4… drh 122 static char aSubst[256];
e1034c4… drh 123
e1034c4… drh 124 /*
e1034c4… drh 125 ** Descramble the password
e1034c4… drh 126 */
e1034c4… drh 127 static void userDescramble(char *z){
e1034c4… drh 128 int i;
e1034c4… drh 129 for(i=0; z[i]; i++) z[i] = aSubst[(unsigned char)z[i]];
e1034c4… drh 130 }
e1034c4… drh 131
e1034c4… drh 132 /* Print a string in 5-letter groups */
e1034c4… drh 133 static void printFive(const unsigned char *z){
e1034c4… drh 134 int i;
e1034c4… drh 135 for(i=0; z[i]; i++){
e1034c4… drh 136 if( i>0 && (i%5)==0 ) putchar(' ');
e1034c4… drh 137 putchar(z[i]);
e1034c4… drh 138 }
e1034c4… drh 139 putchar('\n');
e1034c4… drh 140 }
e1034c4… drh 141
e1034c4… drh 142 /* Return a pseudo-random integer between 0 and N-1 */
e1034c4… drh 143 static int randint(int N){
e1034c4… drh 144 unsigned char x;
e1034c4… drh 145 assert( N<256 );
e1034c4… drh 146 sqlite3_randomness(1, &x);
e1034c4… drh 147 return x % N;
e1034c4… drh 148 }
e1034c4… drh 149
e1034c4… drh 150 /*
e1034c4… drh 151 ** Generate and print a random scrambling of letters a through z (omitting x)
e1034c4… drh 152 ** and set up the aSubst[] matrix to descramble.
e1034c4… drh 153 */
e1034c4… drh 154 static void userGenerateScrambleCode(void){
e1034c4… drh 155 unsigned char zOrig[30];
e1034c4… drh 156 unsigned char zA[30];
e1034c4… drh 157 unsigned char zB[30];
e1034c4… drh 158 int nA = 25;
e1034c4… drh 159 int nB = 0;
e1034c4… drh 160 int i;
e1034c4… drh 161 memcpy(zOrig, "abcdefghijklmnopqrstuvwyz", nA+1);
e1034c4… drh 162 memcpy(zA, zOrig, nA+1);
e1034c4… drh 163 assert( nA==(int)strlen((char*)zA) );
53db40e… drh 164 for(i=0; i<(int)sizeof(aSubst); i++) aSubst[i] = i;
e1034c4… drh 165 printFive(zA);
e1034c4… drh 166 while( nA>0 ){
e1034c4… drh 167 int x = randint(nA);
e1034c4… drh 168 zB[nB++] = zA[x];
e1034c4… drh 169 zA[x] = zA[--nA];
e1034c4… drh 170 }
e1034c4… drh 171 assert( nB==25 );
e1034c4… drh 172 zB[nB] = 0;
e1034c4… drh 173 printFive(zB);
e1034c4… drh 174 for(i=0; i<nB; i++) aSubst[zB[i]] = zOrig[i];
e1034c4… drh 175 }
e1034c4… drh 176
e1034c4… drh 177 /*
e1034c4… drh 178 ** Return the value of the FOSSIL_SECURITY_LEVEL environment variable.
e1034c4… drh 179 ** Or return 0 if that variable does not exist.
e1034c4… drh 180 */
e1034c4… drh 181 int fossil_security_level(void){
e1034c4… drh 182 const char *zLevel = fossil_getenv("FOSSIL_SECURITY_LEVEL");
e1034c4… drh 183 if( zLevel==0 ) return 0;
e1034c4… drh 184 return atoi(zLevel);
e1034c4… drh 185 }
e1034c4… drh 186
d0305b3… aku 187
dbda8d6… drh 188 /*
dbda8d6… drh 189 ** Do a single prompt for a passphrase. Store the results in the blob.
55f3f3d… drh 190 **
55f3f3d… drh 191 **
55f3f3d… drh 192 ** The return value is a pointer to a static buffer that is overwritten
55f3f3d… drh 193 ** on subsequent calls to this same routine.
dbda8d6… drh 194 */
dbda8d6… drh 195 static void prompt_for_passphrase(const char *zPrompt, Blob *pPassphrase){
55f3f3d… drh 196 char *z;
f35b46d… drh 197 #if 0
f35b46d… drh 198 */
f35b46d… drh 199 ** If the FOSSIL_PWREADER environment variable is set, then it will
f35b46d… drh 200 ** be the name of a program that prompts the user for their password/
f35b46d… drh 201 ** passphrase in a secure manner. The program should take one or more
f35b46d… drh 202 ** arguments which are the prompts and should output the acquired
f35b46d… drh 203 ** passphrase as a single line on stdout. This function will read the
f35b46d… drh 204 ** output using popen().
f35b46d… drh 205 **
f35b46d… drh 206 ** If FOSSIL_PWREADER is not set, or if it is not the name of an
f35b46d… drh 207 ** executable, then use the C-library getpass() routine.
f35b46d… drh 208 */
55f3f3d… drh 209 const char *zProg = fossil_getenv("FOSSIL_PWREADER");
55f3f3d… drh 210 if( zProg && zProg[0] ){
55f3f3d… drh 211 static char zPass[100];
55f3f3d… drh 212 Blob cmd;
55f3f3d… drh 213 FILE *in;
55f3f3d… drh 214 blob_zero(&cmd);
55f3f3d… drh 215 blob_appendf(&cmd, "%s \"Fossil Passphrase\" \"%s\"", zProg, zPrompt);
55f3f3d… drh 216 zPass[0] = 0;
55f3f3d… drh 217 in = popen(blob_str(&cmd), "r");
55f3f3d… drh 218 fgets(zPass, sizeof(zPass), in);
55f3f3d… drh 219 pclose(in);
55f3f3d… drh 220 blob_reset(&cmd);
55f3f3d… drh 221 z = zPass;
f35b46d… drh 222 }else
f35b46d… drh 223 #endif
f35b46d… drh 224 if( fossil_security_level()>=2 ){
e1034c4… drh 225 userGenerateScrambleCode();
e1034c4… drh 226 z = getpass(zPrompt);
e1034c4… drh 227 if( z ) userDescramble(z);
e1034c4… drh 228 printf("\033[3A\033[J"); /* Erase previous three lines */
e1034c4… drh 229 fflush(stdout);
55f3f3d… drh 230 }else{
55f3f3d… drh 231 z = getpass(zPrompt);
55f3f3d… drh 232 }
dbda8d6… drh 233 strip_string(pPassphrase, z);
dbda8d6… drh 234 }
dbda8d6… drh 235
dbda8d6… drh 236 /*
e621b6d… drh 237 ** Prompt the user for a password. Store the result in the pPassphrase
e621b6d… drh 238 ** blob.
dbda8d6… drh 239 **
dbda8d6… drh 240 ** Behavior is controlled by the verify parameter:
dbda8d6… drh 241 **
dbda8d6… drh 242 ** 0 Just ask once.
dbda8d6… drh 243 **
dbda8d6… drh 244 ** 1 If the first answer is a non-empty string, ask for
dbda8d6… drh 245 ** verification. Repeat if the two strings do not match.
dbda8d6… drh 246 **
dbda8d6… drh 247 ** 2 Ask twice, repeat if the strings do not match.
dbda8d6… drh 248 */
e621b6d… drh 249 void prompt_for_password(
e621b6d… drh 250 const char *zPrompt,
e621b6d… drh 251 Blob *pPassphrase,
e621b6d… drh 252 int verify
e621b6d… drh 253 ){
dbda8d6… drh 254 Blob secondTry;
dbda8d6… drh 255 blob_zero(pPassphrase);
dbda8d6… drh 256 blob_zero(&secondTry);
dbda8d6… drh 257 while(1){
dbda8d6… drh 258 prompt_for_passphrase(zPrompt, pPassphrase);
dbda8d6… drh 259 if( verify==0 ) break;
dbda8d6… drh 260 if( verify==1 && blob_size(pPassphrase)==0 ) break;
13ceb46… bharder 261 prompt_for_passphrase("Retype new password: ", &secondTry);
dbda8d6… drh 262 if( blob_compare(pPassphrase, &secondTry) ){
d8ec765… drh 263 fossil_print("Passphrases do not match. Try again...\n");
dbda8d6… drh 264 }else{
dbda8d6… drh 265 break;
dbda8d6… drh 266 }
dbda8d6… drh 267 }
dbda8d6… drh 268 blob_reset(&secondTry);
dbda8d6… drh 269 }
dbda8d6… drh 270
dbda8d6… drh 271 /*
dbb5e2d… drh 272 ** Prompt to save Fossil user password
dbb5e2d… drh 273 */
1311841… jan.nijtmans 274 int save_password_prompt(const char *passwd){
dbb5e2d… drh 275 Blob x;
dbb5e2d… drh 276 char c;
e1034c4… drh 277 if( fossil_security_level()>=1 ) return 0;
dbb5e2d… drh 278 prompt_user("remember password (Y/n)? ", &x);
dbb5e2d… drh 279 c = blob_str(&x)[0];
dbb5e2d… drh 280 blob_reset(&x);
dbb5e2d… drh 281 return ( c!='n' && c!='N' );
dbb5e2d… drh 282 }
dbb5e2d… drh 283
dbb5e2d… drh 284 /*
dbb5e2d… drh 285 ** Prompt for Fossil user password
dbb5e2d… drh 286 */
dbb5e2d… drh 287 char *prompt_for_user_password(const char *zUser){
dbb5e2d… drh 288 char *zPrompt = mprintf("\rpassword for %s: ", zUser);
dbb5e2d… drh 289 char *zPw;
dbb5e2d… drh 290 Blob x;
dbb5e2d… drh 291 fossil_force_newline();
dbb5e2d… drh 292 prompt_for_password(zPrompt, &x, 0);
1c9d5cd… stephan 293 fossil_free(zPrompt);
1c9d5cd… stephan 294 zPw = blob_str(&x)/*transfer ownership*/;
dbb5e2d… drh 295 return zPw;
dbb5e2d… drh 296 }
dbb5e2d… drh 297
dbb5e2d… drh 298 /*
dbda8d6… drh 299 ** Prompt the user to enter a single line of text.
dbda8d6… drh 300 */
e37451d… drh 301 void prompt_user(const char *zPrompt, Blob *pIn){
dbda8d6… drh 302 char *z;
dbda8d6… drh 303 char zLine[1000];
eff2b2b… drh 304 blob_init(pIn, 0, 0);
f1ef221… drh 305 fossil_force_newline();
d8ec765… drh 306 fossil_print("%s", zPrompt);
dbda8d6… drh 307 fflush(stdout);
dbda8d6… drh 308 z = fgets(zLine, sizeof(zLine), stdin);
dbda8d6… drh 309 if( z ){
273ec22… drh 310 int n = (int)strlen(z);
273ec22… drh 311 if( n>0 && z[n-1]=='\n' ) fossil_new_line_started();
dbda8d6… drh 312 strip_string(pIn, z);
dbda8d6… drh 313 }
dbda8d6… drh 314 }
dbda8d6… drh 315
dbda8d6… drh 316 /*
841772c… drh 317 ** COMMAND: user*
6607844… drh 318 **
2f7c93f… stephan 319 ** Usage: %fossil user SUBCOMMAND ... ?-R|--repository REPO?
6607844… drh 320 **
6607844… drh 321 ** Run various subcommands on users of the open repository or of
6607844… drh 322 ** the repository identified by the -R or --repository option.
6607844… drh 323 **
e58c76a… drh 324 ** > fossil user capabilities USERNAME ?STRING?
6607844… drh 325 **
6607844… drh 326 ** Query or set the capabilities for user USERNAME
6607844… drh 327 **
03f0a49… jamsek 328 ** > fossil user contact USERNAME ?CONTACT-INFO?
03f0a49… jamsek 329 **
03f0a49… jamsek 330 ** Query or set contact information for user USERNAME
03f0a49… jamsek 331 **
064d20e… drh 332 ** > fossil user default ?OPTIONS? ?USERNAME?
6607844… drh 333 **
6607844… drh 334 ** Query or set the default user. The default user is the
064d20e… drh 335 ** user for command-line interaction. If USERNAME is an
064d20e… drh 336 ** empty string, then the default user is unset from the
064d20e… drh 337 ** repository and will subsequently be determined by the -U
064d20e… drh 338 ** command-line option or by environment variables
064d20e… drh 339 ** FOSSIL_USER, USER, LOGNAME, or USERNAME, in that order.
064d20e… drh 340 ** OPTIONS:
064d20e… drh 341 **
064d20e… drh 342 ** -v|--verbose Show how the default user is computed
6607844… drh 343 **
62cb8ea… drh 344 ** > fossil user list | ls
6607844… drh 345 **
6607844… drh 346 ** List all users known to the repository
6607844… drh 347 **
e58c76a… drh 348 ** > fossil user new ?USERNAME? ?CONTACT-INFO? ?PASSWORD?
6607844… drh 349 **
6607844… drh 350 ** Create a new user in the repository. Users can never be
6607844… drh 351 ** deleted. They can be denied all access but they must continue
6607844… drh 352 ** to exist in the database.
6607844… drh 353 **
e58c76a… drh 354 ** > fossil user password USERNAME ?PASSWORD?
dbda8d6… drh 355 **
6607844… drh 356 ** Change the web access password for a user.
dbda8d6… drh 357 */
dbda8d6… drh 358 void user_cmd(void){
916b6e4… drh 359 int n;
c0c3d92… drh 360 db_find_and_open_repository(0, 0);
dbda8d6… drh 361 if( g.argc<3 ){
652e85c… wyoung 362 usage("capabilities|contact|default|list|new|password ...");
916b6e4… drh 363 }
916b6e4… drh 364 n = strlen(g.argv[2]);
916b6e4… drh 365 if( n>=2 && strncmp(g.argv[2],"new",n)==0 ){
9039a6a… drh 366 Blob passwd, login, caps, contact;
596f3c1… drh 367 char *zPw;
eb804dc… drh 368 blob_init(&caps, db_get("default-perms", 0), -1);
d0305b3… aku 369
d0305b3… aku 370 if( g.argc>=4 ){
f6c0201… drh 371 blob_init(&login, g.argv[3], -1);
d0305b3… aku 372 }else{
d0305b3… aku 373 prompt_user("login: ", &login);
d0305b3… aku 374 }
d0305b3… aku 375 if( db_exists("SELECT 1 FROM user WHERE login=%B", &login) ){
d0305b3… aku 376 fossil_fatal("user %b already exists", &login);
d0305b3… aku 377 }
f6c0201… drh 378 if( g.argc>=5 ){
f6c0201… drh 379 blob_init(&contact, g.argv[4], -1);
f6c0201… drh 380 }else{
f6c0201… drh 381 prompt_user("contact-info: ", &contact);
f6c0201… drh 382 }
f6c0201… drh 383 if( g.argc>=6 ){
f6c0201… drh 384 blob_init(&passwd, g.argv[5], -1);
f6c0201… drh 385 }else{
f6c0201… drh 386 prompt_for_password("password: ", &passwd, 1);
f6c0201… drh 387 }
a257fde… drh 388 zPw = sha1_shared_secret(blob_str(&passwd), blob_str(&login), 0);
c294f6b… stephan 389 db_unprotect(PROTECT_USER);
596f3c1… drh 390 db_multi_exec(
1654456… drh 391 "INSERT INTO user(login,pw,cap,info,mtime)"
1654456… drh 392 "VALUES(%B,%Q,%B,%B,now())",
9039a6a… drh 393 &login, zPw, &caps, &contact
596f3c1… drh 394 );
c294f6b… stephan 395 db_protect_pop();
596f3c1… drh 396 free(zPw);
596f3c1… drh 397 }else if( n>=2 && strncmp(g.argv[2],"default",n)==0 ){
064d20e… drh 398 int eVerbose = find_option("verbose","v",0)!=0;
064d20e… drh 399 verify_all_options();
064d20e… drh 400 if( g.argc>3 ){
064d20e… drh 401 const char *zUser = g.argv[3];
064d20e… drh 402 if( fossil_strcmp(zUser,"")==0 || fossil_stricmp(zUser,"nobody")==0 ){
064d20e… drh 403 db_begin_transaction();
064d20e… drh 404 if( g.localOpen ){
064d20e… drh 405 db_multi_exec("DELETE FROM vvar WHERE name='default-user'");
064d20e… drh 406 }
064d20e… drh 407 db_unset("default-user",0);
064d20e… drh 408 db_commit_transaction();
064d20e… drh 409 }else{
064d20e… drh 410 if( !db_exists("SELECT 1 FROM user WHERE login=%Q", g.argv[3]) ){
064d20e… drh 411 fossil_fatal("no such user: %s", g.argv[3]);
064d20e… drh 412 }
064d20e… drh 413 if( g.localOpen ){
064d20e… drh 414 db_lset("default-user", g.argv[3]);
064d20e… drh 415 }else{
064d20e… drh 416 db_set("default-user", g.argv[3], 0);
064d20e… drh 417 }
064d20e… drh 418 }
064d20e… drh 419 }
064d20e… drh 420 if( g.argc==3 || eVerbose ){
064d20e… drh 421 int eHow = user_select();
064d20e… drh 422 const char *zHow = "???";
064d20e… drh 423 switch( eHow ){
064d20e… drh 424 case 1: zHow = "-U option"; break;
064d20e… drh 425 case 2: zHow = "previously set"; break;
064d20e… drh 426 case 3: zHow = "local check-out"; break;
064d20e… drh 427 case 4: zHow = "repository"; break;
064d20e… drh 428 case 5: zHow = "FOSSIL_USER"; break;
064d20e… drh 429 case 6: zHow = "USER"; break;
064d20e… drh 430 case 7: zHow = "LOGNAME"; break;
064d20e… drh 431 case 8: zHow = "USERNAME"; break;
064d20e… drh 432 case 9: zHow = "URL"; break;
064d20e… drh 433 }
064d20e… drh 434 if( eVerbose ){
064d20e… drh 435 fossil_print("%s (determined by %s)\n", g.zLogin, zHow);
064d20e… drh 436 }else{
064d20e… drh 437 fossil_print("%s\n", g.zLogin);
596f3c1… drh 438 }
596f3c1… drh 439 }
275da70… danield 440 }else if(( n>=2 && strncmp(g.argv[2],"list",n)==0 ) ||
275da70… danield 441 ( n>=2 && strncmp(g.argv[2],"ls",n)==0 )){
dbda8d6… drh 442 Stmt q;
dbda8d6… drh 443 db_prepare(&q, "SELECT login, info FROM user ORDER BY login");
dbda8d6… drh 444 while( db_step(&q)==SQLITE_ROW ){
d8ec765… drh 445 fossil_print("%-12s %s\n", db_column_text(&q, 0), db_column_text(&q, 1));
dbda8d6… drh 446 }
dbda8d6… drh 447 db_finalize(&q);
916b6e4… drh 448 }else if( n>=2 && strncmp(g.argv[2],"password",2)==0 ){
dbda8d6… drh 449 char *zPrompt;
dbda8d6… drh 450 int uid;
dbda8d6… drh 451 Blob pw;
f6c0201… drh 452 if( g.argc!=4 && g.argc!=5 ) usage("password USERNAME ?NEW-PASSWORD?");
916b6e4… drh 453 uid = db_int(0, "SELECT uid FROM user WHERE login=%Q", g.argv[3]);
916b6e4… drh 454 if( uid==0 ){
916b6e4… drh 455 fossil_fatal("no such user: %s", g.argv[3]);
916b6e4… drh 456 }
f6c0201… drh 457 if( g.argc==5 ){
f6c0201… drh 458 blob_init(&pw, g.argv[4], -1);
f6c0201… drh 459 }else{
13ceb46… bharder 460 zPrompt = mprintf("New password for %s: ", g.argv[3]);
f6c0201… drh 461 prompt_for_password(zPrompt, &pw, 1);
f6c0201… drh 462 }
916b6e4… drh 463 if( blob_size(&pw)==0 ){
d8ec765… drh 464 fossil_print("password unchanged\n");
916b6e4… drh 465 }else{
a257fde… drh 466 char *zSecret = sha1_shared_secret(blob_str(&pw), g.argv[3], 0);
f741baa… drh 467 db_unprotect(PROTECT_USER);
1654456… drh 468 db_multi_exec("UPDATE user SET pw=%Q, mtime=now() WHERE uid=%d",
1654456… drh 469 zSecret, uid);
f741baa… drh 470 db_protect_pop();
1862890… stephan 471 fossil_free(zSecret);
916b6e4… drh 472 }
916b6e4… drh 473 }else if( n>=2 && strncmp(g.argv[2],"capabilities",2)==0 ){
916b6e4… drh 474 int uid;
916b6e4… drh 475 if( g.argc!=4 && g.argc!=5 ){
66ca04d… andybradford 476 usage("capabilities USERNAME ?PERMISSIONS?");
66ca04d… andybradford 477 }
66ca04d… andybradford 478 uid = db_int(0, "SELECT uid FROM user WHERE login=%Q", g.argv[3]);
66ca04d… andybradford 479 if( uid==0 ){
66ca04d… andybradford 480 fossil_fatal("no such user: %s", g.argv[3]);
66ca04d… andybradford 481 }
66ca04d… andybradford 482 if( g.argc==5 ){
f741baa… drh 483 db_unprotect(PROTECT_USER);
66ca04d… andybradford 484 db_multi_exec(
66ca04d… andybradford 485 "UPDATE user SET cap=%Q, mtime=now() WHERE uid=%d",
66ca04d… andybradford 486 g.argv[4], uid
66ca04d… andybradford 487 );
f741baa… drh 488 db_protect_pop();
66ca04d… andybradford 489 }
66ca04d… andybradford 490 fossil_print("%s\n", db_text(0, "SELECT cap FROM user WHERE uid=%d", uid));
03f0a49… jamsek 491 }else if( n>=2 && strncmp(g.argv[2], "contact", 2)==0 ){
03f0a49… jamsek 492 int uid;
03f0a49… jamsek 493 if( g.argc!=4 && g.argc!=5 ){
03f0a49… jamsek 494 usage("contact USERNAME ?CONTACT-INFO?");
03f0a49… jamsek 495 }
03f0a49… jamsek 496 uid = db_int(0, "SELECT uid FROM user WHERE login=%Q", g.argv[3]);
03f0a49… jamsek 497 if( uid==0 ){
03f0a49… jamsek 498 fossil_fatal("no such user: %s", g.argv[3]);
03f0a49… jamsek 499 }
03f0a49… jamsek 500 if( g.argc==5 ){
03f0a49… jamsek 501 db_unprotect(PROTECT_USER);
03f0a49… jamsek 502 db_multi_exec(
03f0a49… jamsek 503 "UPDATE user SET info=%Q, mtime=now() WHERE uid=%d",
03f0a49… jamsek 504 g.argv[4], uid
03f0a49… jamsek 505 );
03f0a49… jamsek 506 db_protect_pop();
03f0a49… jamsek 507 }
03f0a49… jamsek 508 fossil_print("%s\n", db_text(0, "SELECT info FROM user WHERE uid=%d", uid));
916b6e4… drh 509 }else{
320f143… drh 510 fossil_fatal("user subcommand should be one of: "
03f0a49… jamsek 511 "capabilities contact default list new password");
dbda8d6… drh 512 }
dbda8d6… drh 513 }
dbda8d6… drh 514
dbda8d6… drh 515 /*
dbda8d6… drh 516 ** Attempt to set the user to zLogin
dbda8d6… drh 517 */
dbda8d6… drh 518 static int attempt_user(const char *zLogin){
dbda8d6… drh 519 int uid;
dbda8d6… drh 520
dbda8d6… drh 521 if( zLogin==0 ){
dbda8d6… drh 522 return 0;
dbda8d6… drh 523 }
dbda8d6… drh 524 uid = db_int(0, "SELECT uid FROM user WHERE login=%Q", zLogin);
dbda8d6… drh 525 if( uid ){
dbda8d6… drh 526 g.userUid = uid;
4c3e172… danield 527 g.zLogin = fossil_strdup(zLogin);
dbda8d6… drh 528 return 1;
dbda8d6… drh 529 }
dbda8d6… drh 530 return 0;
dbda8d6… drh 531 }
dbda8d6… drh 532
dbda8d6… drh 533 /*
dbda8d6… drh 534 ** Figure out what user is at the controls.
dbda8d6… drh 535 **
dbda8d6… drh 536 ** (1) Use the --user and -U command-line options.
dbda8d6… drh 537 **
064d20e… drh 538 ** (2) The name used for login (if there was a login).
064d20e… drh 539 **
064d20e… drh 540 ** (3) If the local database is open, check in VVAR.
064d20e… drh 541 **
064d20e… drh 542 ** (4) Check the default-user in the repository
064d20e… drh 543 **
064d20e… drh 544 ** (5) Try the FOSSIL_USER environment variable.
064d20e… drh 545 **
064d20e… drh 546 ** (6) Try the USER environment variable.
064d20e… drh 547 **
064d20e… drh 548 ** (7) Try the LOGNAME environment variable.
064d20e… drh 549 **
064d20e… drh 550 ** (8) Try the USERNAME environment variable.
064d20e… drh 551 **
064d20e… drh 552 ** (9) Check if the user can be extracted from the remote URL.
dbda8d6… drh 553 **
dbda8d6… drh 554 ** The user name is stored in g.zLogin. The uid is in g.userUid.
dbda8d6… drh 555 */
064d20e… drh 556 int user_select(void){
b9e3629… mistachkin 557 UrlData url;
064d20e… drh 558 if( g.userUid ) return 1;
b120bc8… drh 559 if( g.zLogin ){
b120bc8… drh 560 if( attempt_user(g.zLogin)==0 ){
b120bc8… drh 561 fossil_fatal("no such user: %s", g.zLogin);
b120bc8… drh 562 }else{
064d20e… drh 563 return 2;
1e7262b… drh 564 }
1e7262b… drh 565 }
1e7262b… drh 566
064d20e… drh 567 if( g.localOpen && attempt_user(db_lget("default-user",0)) ) return 3;
064d20e… drh 568
064d20e… drh 569 if( attempt_user(db_get("default-user", 0)) ) return 4;
064d20e… drh 570
064d20e… drh 571 if( attempt_user(fossil_getenv("FOSSIL_USER")) ) return 5;
064d20e… drh 572
064d20e… drh 573 if( attempt_user(fossil_getenv("USER")) ) return 6;
064d20e… drh 574
064d20e… drh 575 if( attempt_user(fossil_getenv("LOGNAME")) ) return 7;
064d20e… drh 576
064d20e… drh 577 if( attempt_user(fossil_getenv("USERNAME")) ) return 8;
b9e3629… mistachkin 578
b9e3629… mistachkin 579 memset(&url, 0, sizeof(url));
0aff8d8… drh 580 url_parse_local(0, URL_USE_CONFIG, &url);
064d20e… drh 581 if( url.user && attempt_user(url.user) ) return 9;
1e7262b… drh 582
1e7262b… drh 583 fossil_print(
f9d053a… mistachkin 584 "Cannot figure out who you are! Consider using the --user\n"
f9d053a… mistachkin 585 "command line option, setting your USER environment variable,\n"
1e7262b… drh 586 "or setting a default user with \"fossil user default USER\".\n"
1e7262b… drh 587 );
1e7262b… drh 588 fossil_fatal("cannot determine user");
1e7262b… drh 589 }
1e7262b… drh 590
1ad4ae2… rberteig 591 /*
1ad4ae2… rberteig 592 ** COMMAND: test-usernames
63220d9… jan.nijtmans 593 **
63220d9… jan.nijtmans 594 ** Usage: %fossil test-usernames
1ad4ae2… rberteig 595 **
1ad4ae2… rberteig 596 ** Print details about sources of fossil usernames.
1ad4ae2… rberteig 597 */
1ad4ae2… rberteig 598 void test_usernames_cmd(void){
1ad4ae2… rberteig 599 db_find_and_open_repository(0, 0);
63220d9… jan.nijtmans 600
1ad4ae2… rberteig 601 fossil_print("Initial g.zLogin: %s\n", g.zLogin);
1ad4ae2… rberteig 602 fossil_print("Initial g.userUid: %d\n", g.userUid);
bc36fdc… danield 603 fossil_print("check-out default-user: %s\n", g.localOpen ?
bc36fdc… danield 604 db_lget("default-user","") : "<<no open check-out>>");
1ad4ae2… rberteig 605 fossil_print("default-user: %s\n", db_get("default-user",""));
1ad4ae2… rberteig 606 fossil_print("FOSSIL_USER: %s\n", fossil_getenv("FOSSIL_USER"));
1ad4ae2… rberteig 607 fossil_print("USER: %s\n", fossil_getenv("USER"));
1ad4ae2… rberteig 608 fossil_print("LOGNAME: %s\n", fossil_getenv("LOGNAME"));
1ad4ae2… rberteig 609 fossil_print("USERNAME: %s\n", fossil_getenv("USERNAME"));
0aff8d8… drh 610 url_parse(0, URL_USE_CONFIG);
1ad4ae2… rberteig 611 fossil_print("URL user: %s\n", g.url.user);
1ad4ae2… rberteig 612 user_select();
1ad4ae2… rberteig 613 fossil_print("Final g.zLogin: %s\n", g.zLogin);
1ad4ae2… rberteig 614 fossil_print("Final g.userUid: %d\n", g.userUid);
1ad4ae2… rberteig 615 }
1ad4ae2… rberteig 616
fcf17b2… drh 617
fcf17b2… drh 618 /*
fcf17b2… drh 619 ** Make sure the USER table is up-to-date. It should contain
fcf17b2… drh 620 ** the "JX" column (as of version 2.21). If it does not, add it.
fcf17b2… drh 621 **
fcf17b2… drh 622 ** The "JX" column is intended to hold a JSON object containing optional
fcf17b2… drh 623 ** key-value pairs.
fcf17b2… drh 624 */
fcf17b2… drh 625 void user_update_user_table(void){
fcf17b2… drh 626 if( db_table_has_column("repository","user","jx")==0 ){
fcf17b2… drh 627 db_multi_exec("ALTER TABLE repository.user"
fcf17b2… drh 628 " ADD COLUMN jx TEXT DEFAULT '{}';");
fcf17b2… drh 629 }
fcf17b2… drh 630 }
596f3c1… drh 631
596f3c1… drh 632 /*
596f3c1… drh 633 ** COMMAND: test-hash-passwords
596f3c1… drh 634 **
596f3c1… drh 635 ** Usage: %fossil test-hash-passwords REPOSITORY
596f3c1… drh 636 **
596f3c1… drh 637 ** Convert all local password storage to use a SHA1 hash of the password
596f3c1… drh 638 ** rather than cleartext. Passwords that are already stored as the SHA1
596f3c1… drh 639 ** has are unchanged.
596f3c1… drh 640 */
596f3c1… drh 641 void user_hash_passwords_cmd(void){
596f3c1… drh 642 if( g.argc!=3 ) usage("REPOSITORY");
596f3c1… drh 643 db_open_repository(g.argv[2]);
a257fde… drh 644 sqlite3_create_function(g.db, "shared_secret", 2, SQLITE_UTF8, 0,
a257fde… drh 645 sha1_shared_secret_sql_function, 0, 0);
f741baa… drh 646 db_unprotect(PROTECT_ALL);
596f3c1… drh 647 db_multi_exec(
1654456… drh 648 "UPDATE user SET pw=shared_secret(pw,login), mtime=now()"
596f3c1… drh 649 " WHERE length(pw)>0 AND length(pw)!=40"
596f3c1… drh 650 );
fcf17b2… drh 651 }
fcf17b2… drh 652
fcf17b2… drh 653 /*
593e801… preben 654 ** Ensure that the password for a user is hashed.
593e801… preben 655 */
593e801… preben 656 void hash_user_password(const char *zUser){
593e801… preben 657 sqlite3_create_function(g.db, "shared_secret", 2, SQLITE_UTF8, 0,
593e801… preben 658 sha1_shared_secret_sql_function, 0, 0);
593e801… preben 659 db_unprotect(PROTECT_USER);
593e801… preben 660 db_multi_exec(
593e801… preben 661 "UPDATE user SET pw=shared_secret(pw,login), mtime=now()"
593e801… preben 662 " WHERE login=%Q AND length(pw)>0 AND length(pw)!=40", zUser
593e801… preben 663 );
593e801… preben 664 db_protect_pop();
593e801… preben 665 }
593e801… preben 666
593e801… preben 667 /*
8817b0e… mistachkin 668 ** COMMAND: test-prompt-user
8817b0e… mistachkin 669 **
8817b0e… mistachkin 670 ** Usage: %fossil test-prompt-user PROMPT
8817b0e… mistachkin 671 **
8817b0e… mistachkin 672 ** Prompts the user for input and then prints it verbatim (i.e. without
8817b0e… mistachkin 673 ** a trailing line terminator).
8817b0e… mistachkin 674 */
8817b0e… mistachkin 675 void test_prompt_user_cmd(void){
8817b0e… mistachkin 676 Blob answer;
8817b0e… mistachkin 677 if( g.argc!=3 ) usage("PROMPT");
8817b0e… mistachkin 678 prompt_user(g.argv[2], &answer);
513dd00… drh 679 fossil_print("%s\n", blob_str(&answer));
513dd00… drh 680 }
513dd00… drh 681
513dd00… drh 682 /*
513dd00… drh 683 ** COMMAND: test-prompt-password
513dd00… drh 684 **
513dd00… drh 685 ** Usage: %fossil test-prompt-password PROMPT VERIFY
513dd00… drh 686 **
513dd00… drh 687 ** Prompts the user for a password and then prints it verbatim.
513dd00… drh 688 **
513dd00… drh 689 ** Behavior is controlled by the VERIFY parameter:
513dd00… drh 690 **
513dd00… drh 691 ** 0 Just ask once.
513dd00… drh 692 **
513dd00… drh 693 ** 1 If the first answer is a non-empty string, ask for
513dd00… drh 694 ** verification. Repeat if the two strings do not match.
513dd00… drh 695 **
513dd00… drh 696 ** 2 Ask twice, repeat if the strings do not match.
513dd00… drh 697
513dd00… drh 698 */
513dd00… drh 699 void test_prompt_password_cmd(void){
513dd00… drh 700 Blob answer;
513dd00… drh 701 int iVerify = 0;
513dd00… drh 702 if( g.argc!=4 ) usage("PROMPT VERIFY");
513dd00… drh 703 iVerify = atoi(g.argv[3]);
513dd00… drh 704 prompt_for_password(g.argv[2], &answer, iVerify);
513dd00… drh 705 fossil_print("[%s]\n", blob_str(&answer));
8817b0e… mistachkin 706 }
8817b0e… mistachkin 707
8817b0e… mistachkin 708 /*
f274222… drh 709 ** WEBPAGE: access_log
b28badb… drh 710 ** WEBPAGE: user_log
7ab0328… drh 711 **
7ab0328… drh 712 ** Show login attempts, including timestamp and IP address.
7ab0328… drh 713 ** Requires Admin privileges.
7ab0328… drh 714 **
7ab0328… drh 715 ** Query parameters:
f274222… drh 716 **
7ab0328… drh 717 ** y=N 1: success only. 2: failure only. 3: both (default: 3)
7ab0328… drh 718 ** n=N Number of entries to show (default: 200)
7ab0328… drh 719 ** o=N Skip this many entries (default: 0)
f274222… drh 720 */
b28badb… drh 721 void user_log_page(void){
e3b3c5c… drh 722 int y = atoi(PD("y","3"));
7ab0328… drh 723 int n = atoi(PD("n","200"));
f274222… drh 724 int skip = atoi(PD("o","0"));
e85eff2… drh 725 const char *zUser = P("u");
e3b3c5c… drh 726 Blob sql;
f274222… drh 727 Stmt q;
e3b3c5c… drh 728 int cnt = 0;
f1efc90… drh 729 int rc;
3967d04… drh 730 int fLogEnabled;
f274222… drh 731
f274222… drh 732 login_check_credentials();
653dd40… drh 733 if( !g.perm.Admin ){ login_needed(0); return; }
c7de5f7… drh 734 create_accesslog_table();
653dd40… drh 735
3967d04… drh 736
a37abec… drh 737 if( P("delall") && P("delallbtn") ){
a37abec… drh 738 db_multi_exec("DELETE FROM accesslog");
b28badb… drh 739 cgi_redirectf("%R/user_log?y=%d&n=%d&o=%o", y, n, skip);
a37abec… drh 740 return;
a37abec… drh 741 }
a37abec… drh 742 if( P("delanon") && P("delanonbtn") ){
a37abec… drh 743 db_multi_exec("DELETE FROM accesslog WHERE uname='anonymous'");
b28badb… drh 744 cgi_redirectf("%R/user_log?y=%d&n=%d&o=%o", y, n, skip);
ba0852c… drh 745 return;
ba0852c… drh 746 }
ba0852c… drh 747 if( P("delfail") && P("delfailbtn") ){
ba0852c… drh 748 db_multi_exec("DELETE FROM accesslog WHERE NOT success");
b28badb… drh 749 cgi_redirectf("%R/user_log?y=%d&n=%d&o=%o", y, n, skip);
a37abec… drh 750 return;
a37abec… drh 751 }
a37abec… drh 752 if( P("delold") && P("deloldbtn") ){
a37abec… drh 753 db_multi_exec("DELETE FROM accesslog WHERE rowid in"
a37abec… drh 754 "(SELECT rowid FROM accesslog ORDER BY rowid DESC"
a37abec… drh 755 " LIMIT -1 OFFSET 200)");
b28badb… drh 756 cgi_redirectf("%R/user_log?y=%d&n=%d", y, n);
a37abec… drh 757 return;
a37abec… drh 758 }
b28badb… drh 759 style_header("User Log");
b28badb… drh 760 style_submenu_element("Log-Menu", "setup-logmenu");
aae2b77… drh 761
e3b3c5c… drh 762 blob_zero(&sql);
49b0ff1… drh 763 blob_append_sql(&sql,
ea63a2d… drh 764 "SELECT uname, ipaddr, datetime(mtime,toLocal()), success"
ea63a2d… drh 765 " FROM accesslog"
e3b3c5c… drh 766 );
e85eff2… drh 767 if( zUser ){
e85eff2… drh 768 blob_append_sql(&sql, " WHERE uname=%Q", zUser);
e85eff2… drh 769 n = 1000000000;
e85eff2… drh 770 skip = 0;
e85eff2… drh 771 }else if( y==1 ){
e3b3c5c… drh 772 blob_append(&sql, " WHERE success", -1);
e3b3c5c… drh 773 }else if( y==2 ){
e3b3c5c… drh 774 blob_append(&sql, " WHERE NOT success", -1);
e3b3c5c… drh 775 }
49b0ff1… drh 776 blob_append_sql(&sql," ORDER BY rowid DESC LIMIT %d OFFSET %d", n+1, skip);
49b0ff1… drh 777 if( skip ){
b28badb… drh 778 style_submenu_element("Newer", "%R/user_log?o=%d&n=%d&y=%d",
a40e8a0… drh 779 skip>=n ? skip-n : 0, n, y);
49b0ff1… drh 780 }
49b0ff1… drh 781 rc = db_prepare_ignore_error(&q, "%s", blob_sql_text(&sql));
fc79c57… drh 782 fLogEnabled = db_get_boolean("access-log", 1);
b28badb… drh 783 @ <div align="center">User logging is %s(fLogEnabled?"on":"off").
3967d04… drh 784 @ (Change this on the <a href="setup_settings">settings</a> page.)</div>
6b645d6… drh 785 @ <table border="1" cellpadding="5" class="sortable" align="center" \
6b645d6… drh 786 @ data-column-types='Ttt' data-init-sort='1'>
0cdec7d… drh 787 @ <thead><tr><th width="33%%">Date</th><th width="34%%">User</th>
0cdec7d… drh 788 @ <th width="33%%">IP Address</th></tr></thead><tbody>
f1efc90… drh 789 while( rc==SQLITE_OK && db_step(&q)==SQLITE_ROW ){
f274222… drh 790 const char *zName = db_column_text(&q, 0);
f274222… drh 791 const char *zIP = db_column_text(&q, 1);
f274222… drh 792 const char *zDate = db_column_text(&q, 2);
f274222… drh 793 int bSuccess = db_column_int(&q, 3);
e3b3c5c… drh 794 cnt++;
e3b3c5c… drh 795 if( cnt>n ){
b28badb… drh 796 style_submenu_element("Older", "%R/user_log?o=%d&n=%d&y=%d",
a40e8a0… drh 797 skip+n, n, y);
e3b3c5c… drh 798 break;
e3b3c5c… drh 799 }
f274222… drh 800 if( bSuccess ){
f274222… drh 801 @ <tr>
f274222… drh 802 }else{
f274222… drh 803 @ <tr bgcolor="#ffacc0">
f274222… drh 804 }
e3b3c5c… drh 805 @ <td>%s(zDate)</td><td>%h(zName)</td><td>%h(zIP)</td></tr>
e3b3c5c… drh 806 }
e3b3c5c… drh 807 if( skip>0 || cnt>n ){
b28badb… drh 808 style_submenu_element("All", "%R/user_log?n=10000000");
f274222… drh 809 }
fcfaae3… jan.nijtmans 810 @ </tbody></table>
f274222… drh 811 db_finalize(&q);
f5482a0… wyoung 812 @ <hr>
b28badb… drh 813 @ <form method="post" action="%R/user_log">
e78c49d… drh 814 @ <label><input type="checkbox" name="delold">
e78c49d… drh 815 @ Delete all but the most recent 200 entries</input></label>
a37abec… drh 816 @ <input type="submit" name="deloldbtn" value="Delete"></input>
a37abec… drh 817 @ </form>
b28badb… drh 818 @ <form method="post" action="%R/user_log">
e78c49d… drh 819 @ <label><input type="checkbox" name="delanon">
e78c49d… drh 820 @ Delete all entries for user "anonymous"</input></label>
a37abec… drh 821 @ <input type="submit" name="delanonbtn" value="Delete"></input>
a37abec… drh 822 @ </form>
b28badb… drh 823 @ <form method="post" action="%R/user_log">
e78c49d… drh 824 @ <label><input type="checkbox" name="delfail">
e78c49d… drh 825 @ Delete all failed login attempts</input></label>
ba0852c… drh 826 @ <input type="submit" name="delfailbtn" value="Delete"></input>
ba0852c… drh 827 @ </form>
b28badb… drh 828 @ <form method="post" action="%R/user_log">
e78c49d… drh 829 @ <label><input type="checkbox" name="delall">
e78c49d… drh 830 @ Delete all entries</input></label>
a37abec… drh 831 @ <input type="submit" name="delallbtn" value="Delete"></input>
a37abec… drh 832 @ </form>
6b645d6… drh 833 style_table_sorter();
112c713… drh 834 style_finish_page();
dbda8d6… drh 835 }

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button