/* * ChatServices: A collection of IRC services * function.c: Miscellaneous functions. * * Copyright (c) 2005-2007 Atheme Project (http://www.atheme.org) * Copyright (c) 2017-2018 ChatLounge IRC Network Development Team (http://www.chatlounge.net) * * Permission to use, copy, modify, and/or distribute this software for any * purpose with or without fee is hereby granted, provided that the above * copyright notice and this permission notice appear in all copies. * * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE * DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING * IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE * POSSIBILITY OF SUCH DAMAGE. */ #include "atheme.h" #include "conf.h" char ch[] = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"; /* This function uses smalloc() to allocate memory. * You MUST free the result when you are done with it! */ char *random_string(int sz) { int i; char *buf = smalloc(sz + 1); /* padding */ for (i = 0; i < sz; i++) { buf[i] = ch[arc4random() % 64]; } buf[sz] = 0; return buf; } /* Shamelessly ripped from https://en.wikipedia.org/wiki/Base36#C_implementation * - Ben */ char *base36_encode(long unsigned int value) { static char base36[36] = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; /* log(2**64) / log(36) = 12.38 => max 13 char + '\0' */ static char buffer[14]; unsigned int offset = sizeof(buffer); buffer[--offset] = '\0'; do { buffer[--offset] = base36[value % 36]; } while (value /= 36); return strncpy(buffer, &buffer[offset], sizeof(buffer)); } long unsigned int base36_decode(const char *text) { return strtoul(text, NULL, 36); } /* Similar to base36_encode but instead utilizes the Natural Area Code which * is base30, as the vowels have been removed. * https://en.wikipedia.org/wiki/Natural_Area_Code * - Ben */ char *naturalareacode_encode(long unsigned int value) { static char naturalareacode[30] = "0123456789BCDFGHJKLMNPQRSTVWXZ"; static char buffer[15]; unsigned int offset = sizeof(buffer); buffer[--offset] = '\0'; do { buffer[--offset] = naturalareacode[value % 30]; } while (value /= 30); return strncpy(buffer, &buffer[offset], sizeof(buffer)); } long unsigned int naturalareacode_decode(const char *text) { return strtoul(text, NULL, 30); } void create_challenge(sourceinfo_t *si, const char *name, int v, char *dest) { char buf[256]; int digest[4]; md5_state_t ctx; snprintf(buf, sizeof buf, "%lu:%s:%s", (unsigned long)(CURRTIME / 300) - v, get_source_name(si), name); md5_init(&ctx); md5_append(&ctx, (unsigned char *)buf, strlen(buf)); md5_finish(&ctx, (unsigned char *)digest); /* note: this depends on byte order, but that's ok because * it's only going to work in the same atheme instance anyway */ snprintf(dest, 80, "%x:%x", digest[0], digest[1]); } #ifdef HAVE_GETTIMEOFDAY /* starts a timer */ void s_time(struct timeval *sttime) { gettimeofday(sttime, NULL); } #endif #ifdef HAVE_GETTIMEOFDAY /* ends a timer */ void e_time(struct timeval sttime, struct timeval *ttime) { struct timeval now; gettimeofday(&now, NULL); timersub(&now, &sttime, ttime); } #endif #ifdef HAVE_GETTIMEOFDAY /* translates microseconds into miliseconds */ int tv2ms(struct timeval *tv) { return (tv->tv_sec * 1000) + (int) (tv->tv_usec / 1000); } #endif /* replaces tabs with a single ASCII 32 */ void tb2sp(char *line) { char *c; while ((c = strchr(line, '\t'))) *c = ' '; } /* replace all occurances of 'old' with 'new' */ char *replace(char *s, int size, const char *old, const char *new) { char *ptr = s; int left = strlen(s); int avail = size - (left + 1); int oldlen = strlen(old); int newlen = strlen(new); int diff = newlen - oldlen; while (left >= oldlen) { if (strncmp(ptr, old, oldlen)) { left--; ptr++; continue; } if (diff > avail) break; if (diff != 0) memmove(ptr + oldlen + diff, ptr + oldlen, left + 1 - oldlen); memcpy(ptr, new, newlen); ptr += newlen; left -= oldlen; } return s; } /* reverse of atoi() */ const char *number_to_string(int num) { static char ret[32]; snprintf(ret, 32, "%d", num); return ret; } /* Used in /NS LIST, /CS LIST, and similar. */ void build_criteriastr(char *buf, int parc, char *parv[]) { int i; return_if_fail(buf != NULL); *buf = 0; for (i = 0; i < parc; i++) { mowgli_strlcat(buf, parv[i], BUFSIZE); mowgli_strlcat(buf, " ", BUFSIZE); } } /* Used in /NS LIST, /CS LIST, and similar. * Accepts a time argument as a character array, returns a time_t. */ time_t parse_age(char *s) { time_t duration; duration = (atol(s) * 60); while (isdigit((unsigned char)*s)) s++; if (*s == 'y' || *s == 'Y') duration *= 525949; /* 365.2422 days per year is 525,948.768 minutes. time_t is an integer, so round it. */ else if (*s == 'h' || *s == 'H') duration *= 60; else if (*s == 'd' || *s == 'D') duration *= 1440; else if (*s == 'w' || *s == 'W') duration *= 10080; else if (*s == '\0') ; else duration = 0; return duration; } /* return the time elapsed since an event */ char *time_ago(time_t event) { static char ret[128]; int years, weeks, days, hours, minutes, seconds; event = CURRTIME - event; years = weeks = days = hours = minutes = 0; while (event >= 60 * 60 * 24 * 365) { event -= 60 * 60 * 24 * 365; years++; } while (event >= 60 * 60 * 24 * 7) { event -= 60 * 60 * 24 * 7; weeks++; } while (event >= 60 * 60 * 24) { event -= 60 * 60 * 24; days++; } while (event >= 60 * 60) { event -= 60 * 60; hours++; } while (event >= 60) { event -= 60; minutes++; } seconds = event; if (years) snprintf(ret, sizeof(ret), "%dy %dw %dd", years, weeks, days); else if (weeks) snprintf(ret, sizeof(ret), "%dw %dd %dh", weeks, days, hours); else if (days) snprintf(ret, sizeof(ret), "%dd %dh %dm %ds", days, hours, minutes, seconds); else if (hours) snprintf(ret, sizeof(ret), "%dh %dm %ds", hours, minutes, seconds); else if (minutes) snprintf(ret, sizeof(ret), "%dm %ds", minutes, seconds); else snprintf(ret, sizeof(ret), "%ds", seconds); return ret; } char *timediff(time_t seconds) { static char buf[BUFSIZE]; long unsigned days, hours, minutes; days = seconds / 86400; seconds %= 86400; hours = seconds / 3600; hours %= 3600; minutes = seconds / 60; minutes %= 60; seconds %= 60; snprintf(buf, sizeof(buf), "%lu day%s, %lu:%02lu:%02lu", days, (days == 1) ? "" : "s", hours, minutes, (long unsigned) seconds); return buf; } /* generate a random number, for use as a key */ unsigned long makekey(void) { unsigned long k; k = arc4random() & 0x7FFFFFFF; /* shorten or pad it to 9 digits */ if (k > 1000000000) k = k - 1000000000; if (k < 100000000) k = k + 100000000; return k; } bool is_internal_client(user_t *u) { return (u && (!u->server || u->server == me.me)); } int validemail(const char *email) { int i, valid = 1, chars = 0, atcnt = 0, dotcnt1 = 0; char c; const char *lastdot = NULL; /* sane length */ if (strlen(email) >= EMAILLEN) return 0; #if 0 /* RFC2822 */ #define EXTRA_ATEXTCHARS "!#$%&'*+-/=?^_`{|}~" #else /* commonly used subset */ #define EXTRA_ATEXTCHARS "%+-=^_" #endif /* note that we do not allow domain literals or quoted strings */ for (i = 0; email[i] != '\0'; i++) { c = email[i]; if (c == '.') { dotcnt1++; lastdot = &email[i]; /* dot may not be first or last, no consecutive dots */ if (i == 0 || email[i - 1] == '.' || email[i - 1] == '@' || email[i + 1] == '\0' || email[i + 1] == '@') return 0; } else if (c == '@') atcnt++, dotcnt1 = 0; else if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || strchr(EXTRA_ATEXTCHARS, c)) chars++; else return 0; } /* must have exactly one @, and at least one . after the @ */ if (atcnt != 1 || dotcnt1 == 0) return 0; /* no mail to IP addresses, this should be done using [10.2.3.4] * like syntax but we do not allow that either */ if (isdigit((unsigned char)lastdot[1])) return 0; /* make sure there are at least 4 characters besides the above * mentioned @ and . */ if (chars < 4) return 0; return valid; } static mowgli_list_t email_canonicalizers; /* Re-canonicalize email addresses. * Call this after adding or removing an email_canonicalize hook. */ static void canonicalize_emails(void) { myentity_iteration_state_t state; myentity_t *mt; MYENTITY_FOREACH_T(mt, &state, ENT_USER) { myuser_t *mu = user(mt); strshare_unref(mu->email_canonical); mu->email_canonical = canonicalize_email(mu->email); } } void register_email_canonicalizer(email_canonicalizer_t func, void *user_data) { email_canonicalizer_item_t *item; item = smalloc(sizeof(email_canonicalizer_item_t)); item->func = func; item->user_data = user_data; mowgli_node_add(item, &item->node, &email_canonicalizers); canonicalize_emails(); } void unregister_email_canonicalizer(email_canonicalizer_t func, void *user_data) { mowgli_node_t *n, *tn; MOWGLI_LIST_FOREACH_SAFE(n, tn, email_canonicalizers.head) { email_canonicalizer_item_t *item = n->data; if (item->func == func && item->user_data == user_data) { mowgli_node_delete(&item->node, &email_canonicalizers); free(item); canonicalize_emails(); return; } } } stringref canonicalize_email(const char *email) { mowgli_node_t *n, *tn; char buf[EMAILLEN + 1]; if (email == NULL) return NULL; mowgli_strlcpy(buf, email, sizeof buf); MOWGLI_LIST_FOREACH_SAFE(n, tn, email_canonicalizers.head) { email_canonicalizer_item_t *item = n->data; item->func(buf, item->user_data); } return strshare_get(buf); } void canonicalize_email_case(char email[EMAILLEN + 1], void *user_data) { strcasecanon(email); } bool email_within_limits(const char *email) { mowgli_node_t *n; myentity_iteration_state_t state; myentity_t *mt; unsigned int tcnt = 0; stringref email_canonical; bool result = true; if (me.maxusers <= 0) return true; MOWGLI_ITER_FOREACH(n, nicksvs.emailexempts.head) { if (0 == match(n->data, email)) return true; } email_canonical = canonicalize_email(email); MYENTITY_FOREACH_T(mt, &state, ENT_USER) { myuser_t *mu = user(mt); if (mu->email_canonical == email_canonical) tcnt++; /* optimization: if tcnt >= me.maxusers, quit iterating. -nenolod */ if (tcnt >= me.maxusers) { result = false; break; } } strshare_unref(email_canonical); return result; } bool validhostmask(const char *host) { char *p, *q; if (strchr(host, ' ')) return false; /* make sure it has ! and @ in that order and only once */ p = strchr(host, '!'); q = strchr(host, '@'); if (p == NULL || q == NULL || p > q || strchr(p + 1, '!') || strchr(q + 1, '@')) return false; /* XXX this NICKLEN is too long */ if (strlen(host) > NICKLEN + USERLEN + HOSTLEN + 1) return false; if (host[0] == ',' || host[0] == '-' || host[0] == '#' || host[0] == '@' || host[0] == '!' || host[0] == ':') return false; return true; } /* char * * pretty_mask(char *mask); * * Input: A mask. * Output: A "user-friendly" version of the mask, in mask_buf. * Side-effects: mask_buf is appended to. mask_pos is incremented. * Notes: The following transitions are made: * x!y@z => x!y@z * y@z => *!y@z * x!y => x!y@* * x => x!*@* * z.d => *!*@z.d * * If either nick/user/host are > than their respective limits, they are * chopped */ char *pretty_mask(char *mask) { static char mask_buf[BUFSIZE]; int old_mask_pos; char star[] = "*"; char *nick = star, *user = star, *host = star; int mask_pos = 0; char *t, *at, *ex; char ne = 0, ue = 0, he = 0; /* save values at nick[NICKLEN], et all */ /* No point wasting CPU time if the mask is already valid */ if (validhostmask(mask)) return mask; if ((size_t) (BUFSIZE - mask_pos) < strlen(mask) + 5) return (NULL); old_mask_pos = mask_pos; at = ex = NULL; if (is_valid_host(mask)) { if (*mask != '\0') host = mask; } else if ((t = strchr(mask, '@')) != NULL) { at = t; *t++ = '\0'; if(*t != '\0') host = t; if((t = strchr(mask, '!')) != NULL) { ex = t; *t++ = '\0'; if(*t != '\0') user = t; if(*mask != '\0') nick = mask; } else { if(*mask != '\0') user = mask; } } else if((t = strchr(mask, '!')) != NULL) { ex = t; *t++ = '\0'; if(*mask != '\0') nick = mask; if(*t != '\0') user = t; } else if(strchr(mask, '.') != NULL && strchr(mask, ':') != NULL) { if(*mask != '\0') host = mask; } else { if(*mask != '\0') nick = mask; } /* truncate values to max lengths */ if(strlen(nick) > NICKLEN - 1) { ne = nick[NICKLEN - 1]; nick[NICKLEN - 1] = '\0'; } if(strlen(user) > USERLEN) { ue = user[USERLEN]; user[USERLEN] = '\0'; } if(strlen(host) > HOSTLEN) { he = host[HOSTLEN]; host[HOSTLEN] = '\0'; } snprintf(mask_buf, sizeof mask_buf, "%s!%s@%s", nick, user, host); /* restore mask, since we may need to use it again later */ if(at) *at = '@'; if(ex) *ex = '!'; if(ne) nick[NICKLEN - 1] = ne; if(ue) user[USERLEN] = ue; if(he) host[HOSTLEN] = he; return mask_buf; } /* check_not_enough_non_wildcard_chars: * * Generalized function to count the number of non wildcard characters * in either a n!u@h or u@h mask. * * Inputs: * - char *: mask * - bool usenick; - Whether a nick is in the mask. * (Used by ChanServ for ACLs. No nick for OperServ AKILL or NickServ ACCESS.) * Side Effects: * None * Returns: * Whether the number of non-wildcard characters. */ bool check_not_enough_non_wildcard_chars(const char* mask, int min_non_wildcard_chars, bool usenick) { int count = 0; const char *p; if(usenick) /* Nick is used, so exclude '!'. */ { for (p = mask; *p; p++) { if (*p != '*' && *p != '?' && *p != '.' && *p != '!' && *p != '@') count++; } } else /* No nick is used, so don't look for '!'. */ { for (p = mask; *p; p++) { if (*p != '*' && *p != '?' && *p != '.' && *p != '@') count++; } } if (min_non_wildcard_chars > count) return true; return false; } bool validtopic(const char *topic) { int i; /* Most server protocols support less than this (depending on * the lengths of the identifiers), but this should catch the * ludicrous stuff. */ if (strlen(topic) > 450) return false; for (i = 0; topic[i] != '\0'; i++) { switch (topic[i]) { case '\r': case '\n': return false; } } if (ircd->flags & IRCD_TOPIC_NOCOLOUR) { for (i = 0; topic[i] != '\0'; i++) { switch (topic[i]) { case 2: case 3: case 6: case 7: case 15: case 22: case 23: case 27: case 31: return false; } } } return true; } bool has_ctrl_chars(const char *text) { int i; for (i = 0; text[i] != '\0'; i++) { if (text[i] > 0 && text[i] < 32) return true; } return false; } #ifndef MOWGLI_OS_WIN static void sendemail_waited(pid_t pid, int status, void *data) { char *email; email = data; if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) slog(LG_INFO, "sendemail_waited(): email for %s failed", email); free(email); } #endif /* send the specified type of email. * * u is whoever caused this to be called, the corresponding service * in case of xmlrpc * type is EMAIL_*, see include/tools.h * mu is the recipient user * param depends on type, also see include/tools.h */ int sendemail(user_t *u, myuser_t *mu, const char *type, const char *email, const char *param) { #ifndef MOWGLI_OS_WIN char *date = NULL; char timebuf[BUFSIZE], to[BUFSIZE], from[BUFSIZE], buf[BUFSIZE], pathbuf[BUFSIZE], sourceinfo[BUFSIZE]; FILE *in, *out; time_t t; struct tm tm; int pipfds[2]; pid_t pid; int rc; static time_t period_start = 0, lastwallops = 0; static unsigned int emailcount = 0; service_t *svs; if (u == NULL || mu == NULL) return 0; if (me.mta == NULL) { if (strcmp(type, EMAIL_MEMO) && !is_internal_client(u)) { svs = service_find("operserv"); notice(svs ? svs->nick : me.name, u->nick, "Sending email is administratively disabled."); } return 0; } if (!validemail(email)) { if (strcmp(type, EMAIL_MEMO) && !is_internal_client(u)) { svs = service_find("operserv"); notice(svs ? svs->nick : me.name, u->nick, "The email address is considered invalid."); } return 0; } if ((unsigned int)(CURRTIME - period_start) > me.emailtime) { emailcount = 0; period_start = CURRTIME; } emailcount++; if (emailcount > me.emaillimit) { if (CURRTIME - lastwallops > 60) { wallops(_("Rejecting email for %s[%s@%s] due to too high load (type '%s' to %s <%s>)"), u->nick, u->user, u->vhost, type, entity(mu)->name, email); slog(LG_ERROR, "sendemail(): rejecting email for %s[%s@%s] (%s) due to too high load (type '%s' to %s <%s>)", u->nick, u->user, u->vhost, u->ip ? u->ip : u->host, type, entity(mu)->name, email); lastwallops = CURRTIME; } return 0; } snprintf(pathbuf, sizeof pathbuf, "%s/%s", SHAREDIR "/email", type); if ((in = fopen(pathbuf, "r")) == NULL) { slog(LG_ERROR, "sendemail(): rejecting email for %s[%s@%s] (%s), due to unknown type '%s'", u->nick, u->user, u->vhost, email, type); return 0; } slog(LG_INFO, "sendemail(): email for %s[%s@%s] (%s) type %s to %s <%s>", u->nick, u->user, u->vhost, u->ip ? u->ip : u->host, type, entity(mu)->name, email); /* set up the email headers */ time(&t); tm = *localtime(&t); strftime(timebuf, sizeof timebuf, "%a, %d %b %Y %H:%M:%S %z", &tm); date = timebuf; snprintf(from, sizeof from, "\"%s Network Services\" <%s>", me.netname, me.register_email); snprintf(to, sizeof to, "\"%s\" <%s>", entity(mu)->name, email); /* \ is special here; escape it */ replace(to, sizeof to, "\\", "\\\\"); snprintf(sourceinfo, sizeof sourceinfo, "%s[%s@%s]", u->nick, u->user, u->vhost); /* now set up the email */ if (pipe(pipfds) < 0) { fclose(in); return 0; } switch (pid = fork()) { case -1: fclose(in); return 0; case 0: connection_close_all_fds(); close(pipfds[1]); dup2(pipfds[0], 0); execl(me.mta, me.mta, "-t", "-f", me.register_email, NULL); _exit(255); } close(pipfds[0]); childproc_add(pid, "email", sendemail_waited, sstrdup(email)); out = fdopen(pipfds[1], "w"); while (fgets(buf, BUFSIZE, in)) { strip(buf); replace(buf, sizeof buf, "&from&", from); replace(buf, sizeof buf, "&to&", to); replace(buf, sizeof buf, "&replyto&", me.adminemail); replace(buf, sizeof buf, "&date&", date); replace(buf, sizeof buf, "&accountname&", entity(mu)->name); replace(buf, sizeof buf, "&entityname&", u->myuser ? entity(u->myuser)->name : u->nick); replace(buf, sizeof buf, "&netname&", me.netname); replace(buf, sizeof buf, "¶m&", param); replace(buf, sizeof buf, "&sourceinfo&", sourceinfo); if ((svs = service_find("nickserv")) != NULL) replace(buf, sizeof buf, "&nicksvs&", svs->me->nick); if ((svs = service_find("chanserv")) != NULL) replace(buf, sizeof buf, "&chansvs&", svs->me->nick); if ((svs = service_find("memoserv")) != NULL) replace(buf, sizeof buf, "&memosvs&", svs->me->nick); if ((svs = service_find("operserv")) != NULL) replace(buf, sizeof buf, "&opersvs&", svs->me->nick); fprintf(out, "%s\n", buf); } fclose(in); rc = 1; if (ferror(out)) rc = 0; if (fclose(out) < 0) rc = 0; if (rc == 0) slog(LG_ERROR, "sendemail(): mta failure"); return rc; #else # warning implement me :( return 0; #endif } /* various access level checkers */ bool is_founder(mychan_t *mychan, myentity_t *mt) { if (mt == NULL) return false; if (chanacs_entity_has_flag(mychan, mt, CA_FOUNDER)) return true; return false; } bool is_ircop(user_t *user) { if (UF_IRCOP & user->flags) return true; return false; } bool is_admin(user_t *user) { if (UF_ADMIN & user->flags) return true; return false; } bool is_autokline_exempt(user_t *user) { mowgli_node_t *n; char buf[BUFSIZE]; snprintf(buf, sizeof(buf), "%s@%s", user->user, user->host); MOWGLI_ITER_FOREACH(n, config_options.exempts.head) { if (0 == match(n->data, buf)) return true; } return false; } bool is_service(user_t *user) { if (UF_SERVICE & user->flags) return true; return false; } char *sbytes(float x) { if (x > 1073741824.0) return "GB"; else if (x > 1048576.0) return "MB"; else if (x > 1024.0) return "KB"; return "B"; } float bytes(float x) { if (x > 1073741824.0) return (x / 1073741824.0); if (x > 1048576.0) return (x / 1048576.0); if (x > 1024.0) return (x / 1024.0); return x; } int srename(const char *old_fn, const char *new_fn) { #ifdef MOWGLI_OS_WIN unlink(new_fn); #endif return rename(old_fn, new_fn); } char *combine_path(const char *parent, const char *child) { char buf[BUFSIZE]; return_val_if_fail(parent != NULL, NULL); return_val_if_fail(child != NULL, NULL); mowgli_strlcpy(buf, parent, sizeof buf); mowgli_strlcat(buf, "/", sizeof buf); mowgli_strlcat(buf, child, sizeof buf); return sstrdup(buf); } /* get_default_uflags: * * Returns a string of the current default uflags * a user receives upon registration. Used in /ns register * and /os info. * * - Ben */ char *get_default_uflags() { char buf[BUFSIZE]; mowgli_strlcpy(buf, "", sizeof buf); int i = 0; for(struct Token *it = uflags; it->value != 0; it++) { if(config_options.defuflags & it->value) { if(i > 0) mowgli_strlcat(buf, ", ", sizeof buf); mowgli_strlcat(buf, it->text, sizeof buf); i++; } } return sstrdup(buf); } /* get_default_uflags: * * Returns a string of the current default uflags * a user receives upon registration. Used in /ns register * and /os info. * * - Ben */ char *get_default_cflags() { char buf[BUFSIZE]; mowgli_strlcpy(buf, "", sizeof buf); int i = 0; for(struct Token *it = cflags; it->value != 0; it++) { if(config_options.defcflags & it->value) { if(i > 0) mowgli_strlcat(buf, ", ", sizeof buf); mowgli_strlcat(buf, it->text, sizeof buf); i++; } } return sstrdup(buf); } /* vim:cinoptions=>s,e0,n0,f0,{0,}0,^0,=s,ps,t0,c3,+s,(2s,us,)20,*30,gs,hs * vim:ts=8 * vim:sw=8 * vim:noexpandtab */