From b0dba4beded88a617ce00a1fb6a710ca967550ec Mon Sep 17 00:00:00 2001 From: Bram Matthys Date: Sun, 29 Mar 2026 19:30:45 +0200 Subject: [PATCH] Add draft/multiline support with a default max-lines of 15 for known-users and 7 for unknown-users (with max-bytes 5250 and 1500 respectively). This allows pasting a short snippet of code, config file, text from a site, etc. With multiline you have the guarantee that: 1) You will see the entire text with no delay between lines 2) You won't see another persons chat half-way through such a paste 3) For multiline supporting clients it is now clear that all the text belongs to each other, which can make selecting/copying it easier. This basically means short snippets/pastes like that can be completely on IRC again. No need for a pastebin for it. Though, you may still need such a service if you are pasting more lines. Regarding the implementation in UnrealIRCd: * Clients without multiline get individual fallback lines (concat lines merged, blank lines skipped, as per spec). And we know that clients like weechat - which does support multiline - also shows all lines and not only a few plus snippet style "[.."]. That is another reason for only allowing 15 lines by default and not something much more. Otherwise all those clients would get a big wall of text, which just sucks. * Spamfilter (also) runs on the full text of all lines together, so splitting a phrase across lines does not evade spamfilter. * Fakelag: a client can send the BATCH start+PRIVMSG (or NOTICE)+BATCH end at full speed. We impose no fake lag there. Also, the multiline default max-lines and max-bytes are lower than the example class::recvq of 8000, so should be perfectly safe. If the entire BATCH is accepted then we will impose fake-lag afterwards, with a cap of 15 seconds maximum. If the BATCH is rejected, we impose half the fakelag plus 2sec. * If the time between BATCH start and BATCH end is more than 15 seconds then the BATCH is rejected (set::multiline::batch-timeout). * The BATCH is atomic (either you see it all, or you see none of it): * When the client sends it to server, it is buffered first. * Only after the batch close the server indicates if it is accepted or rejected. This has various reasons, two of them are: 1) The client is going to send everything in one go anyway and not wait for a response between each PRIVMSG, and 2) we can't do many checks in the buffering stage and skip those after, that would cause a TOCTOU problem (eg. a banned user still being able to speak). * If any line gets rejected due to spamfilter or other case (eg +c, +b ~text with block, etc etc), the entire batch is rejected * Locally we deliver all or nothing (as said) * S2S we buffer the batch as well, so if a server splits after having received 10 lines out of 15, then clients will not see anything. * We send max-lines and max-bytes, this is the hard upper limit. * A multiline can still be limited more tight if: * +f with 't' or 'm' restricts to fewer lines, eg +f [5t]:15, which means max 5 lines per 15 seconds, means the max accepted multiline is 5 for that channel. * +F works the same, except that default +F normal does not have a 't' at the moment and 'm' is very high (50) so practically not limited by default. * There will be a future +f flood subtype for some more control TODO: we will send CAP NEW on unknown-users <-> known-users to indicate the new max-lines value if you transition security groups TODO: chat history does not yet include multiline batches. --- include/h.h | 5 + include/modules.h | 6 + include/struct.h | 6 + src/aliases.c | 18 +- src/api-efunctions.c | 4 + src/conf.c | 141 ++- src/misc.c | 24 +- src/modules/Makefile.in | 2 +- src/modules/batch.c | 26 +- src/modules/chanmodes/floodprot.c | 21 + src/modules/echo-message.c | 4 + src/modules/message-ids.c | 2 +- src/modules/message.c | 54 +- src/modules/multiline.c | 1953 +++++++++++++++++++++++++++++ src/modules/reply-tag.c | 2 +- src/modules/stats.c | 7 + src/modules/tkl.c | 2 +- src/user.c | 9 +- 18 files changed, 2205 insertions(+), 81 deletions(-) create mode 100644 src/modules/multiline.c diff --git a/include/h.h b/include/h.h index 4b6dd402d..5f1a2d0da 100644 --- a/include/h.h +++ b/include/h.h @@ -791,6 +791,7 @@ extern MODVAR int dontspread; extern MODVAR int labeled_response_inhibit; extern MODVAR int labeled_response_inhibit_end; extern MODVAR int labeled_response_force; +extern MODVAR int echo_message_inhibit; /* Efuncs */ extern MODVAR void (*do_join)(Client *, int, const char **); @@ -880,6 +881,7 @@ extern MODVAR int (*is_services_but_not_ulined)(Client *client); extern MODVAR void (*parse_message_tags)(Client *cptr, char **str, MessageTag **mtag_list); extern MODVAR const char *(*mtags_to_string)(MessageTag *m, Client *acptr); extern MODVAR int (*can_send_to_channel)(Client *cptr, Channel *channel, const char **msgtext, const char **errmsg, SendType sendtyp, ClientContext *clictx); +extern MODVAR int (*can_send_to_user)(Client *client, Client *target, const char **msgtext, const char **errmsg, SendType sendtype, ClientContext *clictx, int flags); extern MODVAR void (*broadcast_md_globalvar)(ModDataInfo *mdi, ModData *md); extern MODVAR void (*broadcast_md_globalvar_cmd)(Client *except, Client *sender, const char *varname, const char *value); extern MODVAR int (*tkl_ip_hash)(const char *ip); @@ -948,6 +950,7 @@ extern MODVAR int (*utf8_get_block_number)(const char *name); extern MODVAR void (*send_isupport)(Client *client); extern MODVAR void (*isupport_check_for_changes)(void); extern MODVAR int (*get_connections_from_ip)(Client *client); +extern MODVAR int (*get_floodprot_channel_max_lines)(Channel *channel); /* /Efuncs */ /* TLS functions */ @@ -987,6 +990,7 @@ extern int add_silence_default_handler(Client *client, const char *mask, int sen extern int del_silence_default_handler(Client *client, const char *mask); extern int is_silenced_default_handler(Client *client, Client *acptr); extern int get_connections_from_ip_default_handler(Client *client); +extern int get_floodprot_channel_max_lines_default_handler(Channel *channel); extern void do_unreal_log_remote_deliver_default_handler(LogLevel loglevel, const char *subsystem, const char *event_id, MultiLine *msg, const char *json_serialized); extern int make_oper_default_handler(Client *client, const char *operblock_name, const char *operclass, ConfigItem_class *clientclass, long modes, const char *snomask, const char *vhost, const char *autojoin_channels); extern void webserver_send_response_default_handler(Client *client, int status, char *msg); @@ -1532,6 +1536,7 @@ extern Tag *add_tag(Client *client, const char *name, int value); extern void free_all_tags(Client *client); extern void del_tag(Client *client, const char *name); extern void bump_tag_serial(Client *client); +extern int valid_batch_reference_tag(const char *ref); extern int valid_spamfilter_id(const char *s); extern void download_complete_dontcare(OutgoingWebRequest *request, OutgoingWebResponse *response); extern char *urlencode(const char *s, char *wbuf, int wlen); diff --git a/include/modules.h b/include/modules.h index 899a95a39..bf5c0d67b 100644 --- a/include/modules.h +++ b/include/modules.h @@ -534,6 +534,11 @@ typedef struct { #define MTAG_HANDLER_FLAGS_NONE 0x0 /** This message-tag does not have a CAP REQ xx (eg: for "msgid") */ #define MTAG_HANDLER_FLAGS_NO_CAP_NEEDED 0x1 +/** This tag should only appear on the first message of the + * multiline fallback (for clients that don't support multiline). + * Used by "msgid" and "+draft/reply". + */ +#define MTAG_HANDLER_FLAGS_FIRST_ONLY 0x2 /** Message Tag Handler */ struct MessageTagHandler { @@ -2800,6 +2805,7 @@ enum EfunctionType { EFUNC_SEND_ISUPPORT, EFUNC_ISUPPORT_CHECK_FOR_CHANGES, EFUNC_GET_CONNECTIONS_FROM_IP, + EFUNC_GET_FLOODPROT_CHANNEL_MAX_LINES, }; /* Module flags */ diff --git a/include/struct.h b/include/struct.h index 9aaed087f..6dcae7cdb 100644 --- a/include/struct.h +++ b/include/struct.h @@ -1457,7 +1457,10 @@ typedef enum FloodOption { FLD_CONVERSATIONS = 5, /**< max-concurrent-conversations */ FLD_LAG_PENALTY = 6, /**< lag-penalty / lag-penalty-bytes */ FLD_VHOST = 7, /**< vhost-flood */ + FLD_MULTILINE = 8, /**< multiline max-lines / max-bytes */ } FloodOption; +#define MULTILINE_MAX_CONFIGURABLE_LINES 200 /**< Maximum configurable max-lines for multiline */ +#define MULTILINE_MAX_CONFIGURABLE_BYTES 131072 /**< Maximum configurable max-bytes for multiline (128KB) */ #define MAXFLOODOPTIONS 10 typedef struct TrafficStats TrafficStats; @@ -2662,6 +2665,9 @@ struct ConfigItem_badword { #define SKIP_CTCP 0x8 #define CHECK_INVISIBLE 0x10 +/* Flags for 'flags' in 'can_send_to_user' (and future: can_send_to_channel) */ +#define CAN_SEND_SKIP_SPAMFILTER 0x1 + typedef struct GeoIPResult GeoIPResult; struct GeoIPResult { char *country_code; diff --git a/src/aliases.c b/src/aliases.c index 2e49ee25d..6b6390498 100644 --- a/src/aliases.c +++ b/src/aliases.c @@ -111,10 +111,11 @@ void cmd_alias(ClientContext *clictx, Client *client, MessageTag *mtags, int par { const char *msg = parv[1]; const char *errmsg = NULL; + /* FIXME: when can_send_to_channel() gets 'int flags', pass + * alias->spamfilter ? 0 : CAN_SEND_SKIP_SPAMFILTER + */ if (can_send_to_channel(client, channel, &msg, &errmsg, 0, clictx)) { - if (alias->spamfilter && match_spamfilter(client, parv[1], SPAMF_CHANMSG, cmd, channel->name, 0, clictx, NULL)) - return; new_message(client, NULL, &mtags); sendto_channel(channel, client, client->direction, NULL, 0, SEND_ALL|SKIP_DEAF, mtags, @@ -124,6 +125,8 @@ void cmd_alias(ClientContext *clictx, Client *client, MessageTag *mtags, int par return; } } + if (IsDead(client)) + return; sendnumeric(client, ERR_CANNOTDOCOMMAND, cmd, "You may not use this command at this time"); } @@ -249,10 +252,11 @@ void cmd_alias(ClientContext *clictx, Client *client, MessageTag *mtags, int par { const char *msg = output; const char *errmsg = NULL; - if (!can_send_to_channel(client, channel, &msg, &errmsg, 0, clictx)) + /* FIXME: when can_send_to_channel() gets 'int flags', pass + * alias->spamfilter ? 0 : CAN_SEND_SKIP_SPAMFILTER + */ + if (can_send_to_channel(client, channel, &msg, &errmsg, 0, clictx)) { - if (alias->spamfilter && match_spamfilter(client, output, SPAMF_CHANMSG, cmd, channel->name, 0, clictx, NULL)) - return; new_message(client, NULL, &mtags); sendto_channel(channel, client, client->direction, NULL, 0, SEND_ALL|SKIP_DEAF, mtags, @@ -262,7 +266,9 @@ void cmd_alias(ClientContext *clictx, Client *client, MessageTag *mtags, int par return; } } - sendnumeric(client, ERR_CANNOTDOCOMMAND, cmd, + if (IsDead(client)) + return; + sendnumeric(client, ERR_CANNOTDOCOMMAND, cmd, "You may not use this command at this time"); } else if (format->type == ALIAS_REAL) diff --git a/src/api-efunctions.c b/src/api-efunctions.c index 5f5108bf3..17ac0d42f 100644 --- a/src/api-efunctions.c +++ b/src/api-efunctions.c @@ -118,6 +118,7 @@ int (*is_services_but_not_ulined)(Client *client); void (*parse_message_tags)(Client *client, char **str, MessageTag **mtag_list); const char *(*mtags_to_string)(MessageTag *m, Client *client); int (*can_send_to_channel)(Client *client, Channel *channel, const char **msgtext, const char **errmsg, SendType sendtype, ClientContext *clictx); +int (*can_send_to_user)(Client *client, Client *target, const char **msgtext, const char **errmsg, SendType sendtype, ClientContext *clictx, int flags); void (*broadcast_md_globalvar)(ModDataInfo *mdi, ModData *md); void (*broadcast_md_globalvar_cmd)(Client *except, Client *sender, const char *varname, const char *value); int (*tkl_ip_hash)(const char *ip); @@ -191,6 +192,7 @@ int (*utf8_get_block_number)(const char *name); void (*send_isupport)(Client *client); void (*isupport_check_for_changes)(void); int (*get_connections_from_ip)(Client *client); +int (*get_floodprot_channel_max_lines)(Channel *channel); Efunction *EfunctionAddMain(Module *module, EfunctionType eftype, int (*func)(), void (*vfunc)(), void *(*pvfunc)(), char *(*stringfunc)(), const char *(*conststringfunc)()) { @@ -457,6 +459,7 @@ void efunctions_init(void) efunc_init_function(EFUNC_TKL_TYPE_STRING, tkl_type_string, NULL, 0); efunc_init_function(EFUNC_TKL_TYPE_CONFIG_STRING, tkl_type_config_string, NULL, 0); efunc_init_function(EFUNC_CAN_SEND_TO_CHANNEL, can_send_to_channel, NULL, 0); + efunc_init_function(EFUNC_CAN_SEND_TO_USER, can_send_to_user, NULL, 0); efunc_init_function(EFUNC_BROADCAST_MD_GLOBALVAR, broadcast_md_globalvar, NULL, 0); efunc_init_function(EFUNC_BROADCAST_MD_GLOBALVAR_CMD, broadcast_md_globalvar_cmd, NULL, 0); efunc_init_function(EFUNC_TKL_IP_HASH, tkl_ip_hash, NULL, 0); @@ -533,4 +536,5 @@ void efunctions_init(void) efunc_init_function(EFUNC_SEND_ISUPPORT, send_isupport, NULL, 0); efunc_init_function(EFUNC_ISUPPORT_CHECK_FOR_CHANGES, isupport_check_for_changes, NULL, 0); efunc_init_function(EFUNC_GET_CONNECTIONS_FROM_IP, get_connections_from_ip, get_connections_from_ip_default_handler, 0); + efunc_init_function(EFUNC_GET_FLOODPROT_CHANNEL_MAX_LINES, get_floodprot_channel_max_lines, get_floodprot_channel_max_lines_default_handler, 0); } diff --git a/src/conf.c b/src/conf.c index 7a2b6e054..882a7f83d 100644 --- a/src/conf.c +++ b/src/conf.c @@ -430,6 +430,7 @@ int flood_option_is_old(const char *name) "knock-flood", "connect-flood", "target-flood", + "multiline", NULL }; @@ -1829,6 +1830,7 @@ void config_setdefaultsettings(Configuration *i) config_parse_flood_generic("4:120", i, "known-users", FLD_KNOCK); /* KNOCK protection: max 4 per 120s */ config_parse_flood_generic("10:15", i, "known-users", FLD_CONVERSATIONS); /* 10 users, new user every 15s */ config_parse_flood_generic("180:750", i, "known-users", FLD_LAG_PENALTY); /* 180 bytes / 750 msec */ + config_parse_flood_generic("15:5250", i, "known-users", FLD_MULTILINE); /* max-lines=15, max-bytes=5250 */ /* - unknown-users */ config_parse_flood_generic("2:60", i, "unknown-users", FLD_NICK); /* NICK flood protection: max 2 per 60s */ config_parse_flood_generic("2:90", i, "unknown-users", FLD_JOIN); /* JOIN flood protection: max 2 per 90s */ @@ -1838,6 +1840,7 @@ void config_setdefaultsettings(Configuration *i) config_parse_flood_generic("2:120", i, "unknown-users", FLD_KNOCK); /* KNOCK protection: max 2 per 120s */ config_parse_flood_generic("4:15", i, "unknown-users", FLD_CONVERSATIONS); /* 4 users, new user every 15s */ config_parse_flood_generic("90:1000", i, "unknown-users", FLD_LAG_PENALTY); /* 90 bytes / 1000 msec */ + config_parse_flood_generic("7:1500", i, "unknown-users", FLD_MULTILINE); /* max-lines=7, max-bytes=1500 */ /* TLS options */ i->tls_options = safe_alloc(sizeof(TLSOptions)); @@ -8074,6 +8077,22 @@ int _conf_set(ConfigFile *conf, ConfigEntry *ce) snprintf(buf, sizeof(buf), "%d:%ld", users, every); config_parse_flood_generic(buf, &tempiConf, cepp->name, FLD_CONVERSATIONS); } + else if (!strcmp(ceppp->name, "multiline")) + { + /* Same hack: store max-lines in limit, max-bytes in period */ + char buf[64]; + int max_lines = 0; + int max_bytes = 0; + for (cep4 = ceppp->items; cep4; cep4 = cep4->next) + { + if (!strcmp(cep4->name, "max-lines")) + max_lines = atoi(cep4->value); + else if (!strcmp(cep4->name, "max-bytes")) + max_bytes = config_checkval(cep4->value, CFG_SIZE); + } + snprintf(buf, sizeof(buf), "%d:%d", max_lines, max_bytes); + config_parse_flood_generic(buf, &tempiConf, cepp->name, FLD_MULTILINE); + } } if ((lag_penalty != -1) && (lag_penalty_bytes != -1)) { @@ -9006,6 +9025,44 @@ int _test_set(ConfigFile *conf, ConfigEntry *ce) } continue; /* required here, due to checknull directly below */ } + else if (!strcmp(ceppp->name, "multiline")) + { + for (cep4 = ceppp->items; cep4; cep4 = cep4->next) + { + CheckNull(cep4); + if (!strcmp(cep4->name, "max-lines")) + { + int v = atoi(cep4->value); + if ((v < 2) || (v > MULTILINE_MAX_CONFIGURABLE_LINES)) + { + config_error("%s:%i: set::anti-flood::multiline::max-lines: " + "value should be between 2 and %d", + cep4->file->filename, cep4->line_number, + MULTILINE_MAX_CONFIGURABLE_LINES); + errors++; + } + } else + if (!strcmp(cep4->name, "max-bytes")) + { + int v = config_checkval(cep4->value, CFG_SIZE); + if ((v < 256) || (v > MULTILINE_MAX_CONFIGURABLE_BYTES)) + { + config_error("%s:%i: set::anti-flood::multiline::max-bytes: " + "value should be between 256 and %d", + cep4->file->filename, cep4->line_number, + MULTILINE_MAX_CONFIGURABLE_BYTES); + errors++; + } + } else + { + config_error_unknownopt(cep4->file->filename, + cep4->line_number, "set::anti-flood::multiline", + cep4->name); + errors++; + } + } + continue; + } else if (!strcmp(ceppp->name, "maxchannelsperuser")) { CheckNull(ceppp); @@ -10336,6 +10393,26 @@ int _test_offchans(ConfigFile *conf, ConfigEntry *ce) return errors; } +/** Convert alias type string to ALIAS_xxx constant. + * @returns ALIAS_SERVICES, ALIAS_STATS, etc., or 0 for unknown. + */ +static int alias_type_strtoval(const char *s) +{ + if (!strcmp(s, "services")) + return ALIAS_SERVICES; + if (!strcmp(s, "stats")) + return ALIAS_STATS; + if (!strcmp(s, "normal")) + return ALIAS_NORMAL; + if (!strcmp(s, "command")) + return ALIAS_COMMAND; + if (!strcmp(s, "channel")) + return ALIAS_CHANNEL; + if (!strcmp(s, "real")) + return ALIAS_REAL; + return 0; +} + int _conf_alias(ConfigFile *conf, ConfigEntry *ce) { ConfigItem_alias *alias = NULL; @@ -10374,16 +10451,7 @@ int _conf_alias(ConfigFile *conf, ConfigEntry *ce) safe_strdup(format->parameters, cepp->value); } else if (!strcmp(cepp->name, "type")) { - if (!strcmp(cepp->value, "services")) - format->type = ALIAS_SERVICES; - else if (!strcmp(cepp->value, "stats")) - format->type = ALIAS_STATS; - else if (!strcmp(cepp->value, "normal")) - format->type = ALIAS_NORMAL; - else if (!strcmp(cepp->value, "channel")) - format->type = ALIAS_CHANNEL; - else if (!strcmp(cepp->value, "real")) - format->type = ALIAS_REAL; + format->type = alias_type_strtoval(cepp->value); } } AddListItem(format, alias->format); @@ -10394,16 +10462,7 @@ int _conf_alias(ConfigFile *conf, ConfigEntry *ce) safe_strdup(alias->nick, cep->value); } else if (!strcmp(cep->name, "type")) { - if (!strcmp(cep->value, "services")) - alias->type = ALIAS_SERVICES; - else if (!strcmp(cep->value, "stats")) - alias->type = ALIAS_STATS; - else if (!strcmp(cep->value, "normal")) - alias->type = ALIAS_NORMAL; - else if (!strcmp(cep->value, "channel")) - alias->type = ALIAS_CHANNEL; - else if (!strcmp(cep->value, "command")) - alias->type = ALIAS_COMMAND; + alias->type = alias_type_strtoval(cep->value); } else if (!strcmp(cep->name, "spamfilter")) alias->spamfilter = config_checkval(cep->value, CFG_YESNO); @@ -10421,8 +10480,8 @@ int _conf_alias(ConfigFile *conf, ConfigEntry *ce) int _test_alias(ConfigFile *conf, ConfigEntry *ce) { int errors = 0; ConfigEntry *cep, *cepp; - char has_type = 0, has_target = 0, has_format = 0; - char type = 0; + char has_type = 0, has_target = 0, has_format = 0, has_spamfilter = 0; + int type = 0; if (!ce->items) { @@ -10492,17 +10551,7 @@ int _test_alias(ConfigFile *conf, ConfigEntry *ce) { continue; } has_type = 1; - if (!strcmp(cepp->value, "services")) - ; - else if (!strcmp(cepp->value, "stats")) - ; - else if (!strcmp(cepp->value, "normal")) - ; - else if (!strcmp(cepp->value, "channel")) - ; - else if (!strcmp(cepp->value, "real")) - ; - else + if (!alias_type_strtoval(cepp->value)) { config_error("%s:%i: unknown alias type", cepp->file->filename, cepp->line_number); @@ -10565,24 +10614,16 @@ int _test_alias(ConfigFile *conf, ConfigEntry *ce) { continue; } has_type = 1; - if (!strcmp(cep->value, "services")) - ; - else if (!strcmp(cep->value, "stats")) - ; - else if (!strcmp(cep->value, "normal")) - ; - else if (!strcmp(cep->value, "channel")) - ; - else if (!strcmp(cep->value, "command")) - type = 'c'; - else { + type = alias_type_strtoval(cep->value); + if (!type) + { config_error("%s:%i: unknown alias type", cep->file->filename, cep->line_number); errors++; } } else if (!strcmp(cep->name, "spamfilter")) - ; + has_spamfilter = 1; else { config_error_unknown(cep->file->filename, cep->line_number, "alias", cep->name); @@ -10595,18 +10636,24 @@ int _test_alias(ConfigFile *conf, ConfigEntry *ce) { "alias::type"); errors++; } - if (!has_format && type == 'c') + if (!has_format && (type == ALIAS_COMMAND)) { config_error("%s:%d: alias::type is 'command' but no alias::format was specified", ce->file->filename, ce->line_number); errors++; } - else if (has_format && type != 'c') + else if (has_format && (type != ALIAS_COMMAND)) { config_error("%s:%d: alias::format specified when type is not 'command'", ce->file->filename, ce->line_number); errors++; } + if (has_spamfilter && (type == ALIAS_CHANNEL)) + { + config_warn("%s:%d: alias::spamfilter has no effect for channel aliases, " + "spamfilter is always checked for channel messages", + ce->file->filename, ce->line_number); + } return errors; } diff --git a/src/misc.c b/src/misc.c index a2089947a..1f180dbb3 100644 --- a/src/misc.c +++ b/src/misc.c @@ -1469,6 +1469,11 @@ int get_connections_from_ip_default_handler(Client *client) return 0; } +int get_floodprot_channel_max_lines_default_handler(Channel *channel) +{ + return INT_MAX; +} + int make_oper_default_handler(Client *client, const char *operblock_name, const char *operclass, ConfigItem_class *clientclass, long modes, const char *snomask, const char *vhost, const char *autojoin_channels) @@ -2832,7 +2837,7 @@ const char *StripControlCodesEx(const char *text, char *output, size_t outputlen /* strip color, bold, underline, and reverse codes from a string */ const char *StripControlCodes(const char *text) { - static unsigned char new_str[4096]; + static unsigned char new_str[8192]; return StripControlCodesEx(text, new_str, sizeof(new_str), 0); } @@ -2883,6 +2888,23 @@ int valid_spamfilter_id(const char *s) return 1; } +/** Check if a batch reference tag contains only valid characters. + * Per the batch spec: only ASCII letters, numbers, and hyphens. + */ +int valid_batch_reference_tag(const char *ref) +{ + const char *p; + + if (BadPtr(ref) || strlen(ref) > 48) + return 0; + for (p = ref; *p; p++) + { + if (!isalnum(*p) && *p != '-') + return 0; + } + return 1; +} + void download_complete_dontcare(OutgoingWebRequest *request, OutgoingWebResponse *response) { #ifdef DEBUGMODE diff --git a/src/modules/Makefile.in b/src/modules/Makefile.in index 6df58ee9b..e088aa1c3 100644 --- a/src/modules/Makefile.in +++ b/src/modules/Makefile.in @@ -69,7 +69,7 @@ MODULES= \ history_backend_null.so tkldb.so channeldb.so whowasdb.so \ restrict-commands.so rmtkl.so require-module.so \ account-notify.so \ - message-tags.so batch.so \ + message-tags.so batch.so multiline.so \ account-tag.so labeled-response.so link-security.so \ message-ids.so plaintext-policy.so server-time.so sts.so \ echo-message.so userip-tag.so userhost-tag.so geoip-tag.so \ diff --git a/src/modules/batch.c b/src/modules/batch.c index 692680c4d..bd88a775f 100644 --- a/src/modules/batch.c +++ b/src/modules/batch.c @@ -80,7 +80,24 @@ CMD_FUNC(cmd_batch) Client *target; char buf[512]; - if (MyUser(client) || (parc < 3)) + if (MyUser(client)) + { + /* No module claimed this client-initiated batch */ + if (parc >= 2 && parv[1][0] == '+') + { + if (!valid_batch_reference_tag(parv[1] + 1)) + sendto_one(client, NULL, ":%s FAIL BATCH INVALID_REFTAG %s :Invalid batch reference tag", me.name, parv[1] + 1); + else + sendto_one(client, NULL, ":%s FAIL BATCH UNKNOWN_TYPE :Unknown batch type", me.name); + } + return; + } + if (parc < 3) + return; + + if (parv[2][0] != '+' && parv[2][0] != '-') + return; + if (!valid_batch_reference_tag(parv[2] + 1)) return; target = find_client(parv[1], NULL); @@ -109,12 +126,17 @@ CMD_FUNC(cmd_batch) /** This function verifies if the client sending * 'batch' is permitted to do so and uses a permitted * syntax. - * We simply allow batch ONLY from servers and with any syntax. + * We allow batch from servers (with any syntax) and from + * local users (so modules handling client-initiated batches, + * like draft/multiline, can see the tag on recv_mtags). */ int batch_mtag_is_ok(Client *client, const char *name, const char *value) { if (IsServer(client)) return 1; + if (MyUser(client) && !BadPtr(value) && valid_batch_reference_tag(value)) + return 1; + return 0; } diff --git a/src/modules/chanmodes/floodprot.c b/src/modules/chanmodes/floodprot.c index aa578c793..5824cc4ea 100644 --- a/src/modules/chanmodes/floodprot.c +++ b/src/modules/chanmodes/floodprot.c @@ -181,11 +181,13 @@ int parse_channel_mode_flood_failed(const char **error_out, ChannelFloodProtecti int floodprot_server_quit(Client *client, MessageTag *mtags); void inherit_settings(ChannelFloodProtection *from, ChannelFloodProtection *to); void reapply_profiles(void); +int _get_floodprot_channel_max_lines(Channel *channel); MOD_TEST() { HookAdd(modinfo->handle, HOOKTYPE_CONFIGTEST, 0, floodprot_config_test_set_block); HookAdd(modinfo->handle, HOOKTYPE_CONFIGTEST, 0, floodprot_config_test_antiflood_block); + EfunctionAdd(modinfo->handle, EFUNC_GET_FLOODPROT_CHANNEL_MAX_LINES, _get_floodprot_channel_max_lines); return MOD_SUCCESS; } @@ -1301,6 +1303,25 @@ ChannelFloodProtection *get_channel_flood_settings(Channel *channel, int what) return NULL; } +/** Return the maximum number of lines permitted by +f/+F 'm' and 't' limits. + * Returns INT_MAX if no relevant flood protection is set. + */ +int _get_floodprot_channel_max_lines(Channel *channel) +{ + ChannelFloodProtection *fld; + int result = INT_MAX; + + fld = get_channel_flood_settings(channel, CHFLD_MSG); + if (fld && fld->limit[CHFLD_MSG]) + result = MIN(result, fld->limit[CHFLD_MSG]); + + fld = get_channel_flood_settings(channel, CHFLD_TEXT); + if (fld && fld->limit[CHFLD_TEXT]) + result = MIN(result, fld->limit[CHFLD_TEXT]); + + return result; +} + int floodprot_can_send_to_channel(Client *client, Channel *channel, Membership *lp, const char **msg, const char **errmsg, SendType sendtype, ClientContext *clictx) { Membership *mb; diff --git a/src/modules/echo-message.c b/src/modules/echo-message.c index 3750267fd..2e814643e 100644 --- a/src/modules/echo-message.c +++ b/src/modules/echo-message.c @@ -66,6 +66,8 @@ MOD_UNLOAD() int em_chanmsg(Client *client, Channel *channel, int sendflags, const char *prefix, const char *target, MessageTag *mtags, const char *text, SendType sendtype) { + if (echo_message_inhibit) + return 0; if (MyUser(client) && HasCapabilityFast(client, CAP_ECHO_MESSAGE)) { if (sendtype != SEND_TYPE_TAGMSG) @@ -87,6 +89,8 @@ int em_chanmsg(Client *client, Channel *channel, int sendflags, const char *pref int em_usermsg(Client *client, Client *to, MessageTag *mtags, const char *text, SendType sendtype) { + if (echo_message_inhibit) + return 0; if (MyUser(client) && HasCapabilityFast(client, CAP_ECHO_MESSAGE)) { if (sendtype != SEND_TYPE_TAGMSG) diff --git a/src/modules/message-ids.c b/src/modules/message-ids.c index ec44b8c9e..3fe0c97fd 100644 --- a/src/modules/message-ids.c +++ b/src/modules/message-ids.c @@ -46,7 +46,7 @@ MOD_INIT() memset(&mtag, 0, sizeof(mtag)); mtag.name = "msgid"; mtag.is_ok = msgid_mtag_is_ok; - mtag.flags = MTAG_HANDLER_FLAGS_NO_CAP_NEEDED; + mtag.flags = MTAG_HANDLER_FLAGS_NO_CAP_NEEDED | MTAG_HANDLER_FLAGS_FIRST_ONLY; MessageTagHandlerAdd(modinfo->handle, &mtag); HookAddVoid(modinfo->handle, HOOKTYPE_NEW_MESSAGE, 0, mtag_add_or_inherit_msgid); diff --git a/src/modules/message.c b/src/modules/message.c index a7a1dafd1..954d93648 100644 --- a/src/modules/message.c +++ b/src/modules/message.c @@ -28,7 +28,7 @@ CMD_FUNC(cmd_notice); CMD_FUNC(cmd_tagmsg); void cmd_message(ClientContext *clictx, Client *client, MessageTag *recv_mtags, int parc, const char *parv[], SendType sendtype); int _can_send_to_channel(Client *client, Channel *channel, const char **msgtext, const char **errmsg, SendType sendtype, ClientContext *clictx); -int can_send_to_user(Client *client, Client *target, const char **msgtext, const char **errmsg, SendType sendtype, ClientContext *clictx); +int _can_send_to_user(Client *client, Client *target, const char **msgtext, const char **errmsg, SendType sendtype, ClientContext *clictx, int flags); /* Variables */ long CAP_MESSAGE_TAGS = 0; /**< Looked up at MOD_LOAD, may stay 0 if message-tags support is absent */ @@ -47,6 +47,7 @@ MOD_TEST() MARK_AS_OFFICIAL_MODULE(modinfo); EfunctionAddConstString(modinfo->handle, EFUNC_STRIPCOLORS, _StripColors); EfunctionAdd(modinfo->handle, EFUNC_CAN_SEND_TO_CHANNEL, _can_send_to_channel); + EfunctionAdd(modinfo->handle, EFUNC_CAN_SEND_TO_USER, _can_send_to_user); return MOD_SUCCESS; } @@ -83,7 +84,7 @@ MOD_UNLOAD() * text: Pointer to a pointer to a text [in, out] * cmd: Pointer to a pointer which contains the command to use [in, out] */ -int can_send_to_user(Client *client, Client *target, const char **msgtext, const char **errmsg, SendType sendtype, ClientContext *clictx) +int _can_send_to_user(Client *client, Client *target, const char **msgtext, const char **errmsg, SendType sendtype, ClientContext *clictx, int flags) { int ret; Hook *h; @@ -114,12 +115,14 @@ int can_send_to_user(Client *client, Client *target, const char **msgtext, const return 0; } - // Possible FIXME: make match_spamfilter also use errmsg, or via a wrapper? or use same numeric? - if (MyUser(client) && (sendtype != SEND_TYPE_TAGMSG)) + /* Spamfilter: run on original text, before hooks may transform it + * (e.g. +G censor). This ensures spamfilter catches the actual + * input even if a user mode would sanitize it for delivery. + */ + if (!(flags & CAN_SEND_SKIP_SPAMFILTER) && MyUser(client) && (sendtype != SEND_TYPE_TAGMSG)) { int spamtype = (sendtype == SEND_TYPE_NOTICE ? SPAMF_USERNOTICE : SPAMF_USERMSG); const char *cmd = sendtype_to_cmd(sendtype); - if (match_spamfilter(client, *msgtext, spamtype, cmd, target->name, 0, clictx, NULL)) return 0; } @@ -291,12 +294,6 @@ void cmd_message(ClientContext *clictx, Client *client, MessageTag *recv_mtags, targetstr = pfixchan; } - if (IsVirus(client) && strcasecmp(channel->name, SPAMFILTER_VIRUSCHAN)) - { - sendnotice(client, "You are only allowed to talk in '%s'", SPAMFILTER_VIRUSCHAN); - continue; - } - text = parv[2]; errmsg = NULL; if (MyUser(client) && !IsULine(client)) @@ -323,14 +320,6 @@ void cmd_message(ClientContext *clictx, Client *client, MessageTag *recv_mtags, if ((*parv[2] == '\001') && strncmp(&parv[2][1], "ACTION ", 7)) sendflags |= SKIP_CTCP; - if (MyUser(client) && (sendtype != SEND_TYPE_TAGMSG)) - { - int spamtype = (sendtype == SEND_TYPE_NOTICE ? SPAMF_CHANNOTICE : SPAMF_CHANMSG); - - if (match_spamfilter(client, text, spamtype, cmd, channel->name, 0, clictx, NULL)) - return; - } - new_message(client, recv_mtags, &mtags); RunHook(HOOKTYPE_PRE_CHANMSG, client, channel, &mtags, text, sendtype); @@ -409,7 +398,7 @@ void cmd_message(ClientContext *clictx, Client *client, MessageTag *recv_mtags, { const char *errmsg = NULL; text = parv[2]; - if (!can_send_to_user(client, target, &text, &errmsg, sendtype, clictx)) + if (!can_send_to_user(client, target, &text, &errmsg, sendtype, clictx, 0)) { /* Message is discarded */ if (IsDead(client)) @@ -627,8 +616,14 @@ int ban_version(Client *client, const char *text) * @returns Returns 1 if the user is allowed to send, otherwise 0. * (note that this behavior was reversed in UnrealIRCd versions <5.x. */ +/* FIXME: in a future major release, add 'int flags' parameter + * (matching can_send_to_user) so callers like aliases.c can pass + * CAN_SEND_SKIP_SPAMFILTER to opt out of the built-in spamfilter + * check. Can't add the parameter now without breaking the module API. + */ int _can_send_to_channel(Client *client, Channel *channel, const char **msgtext, const char **errmsg, SendType sendtype, ClientContext *clictx) { + static char errbuf[256]; Membership *lp; int member, i = 0; Hook *h; @@ -638,6 +633,25 @@ int _can_send_to_channel(Client *client, Channel *channel, const char **msgtext, *errmsg = NULL; + if (IsVirus(client) && strcasecmp(channel->name, SPAMFILTER_VIRUSCHAN)) + { + ircsnprintf(errbuf, sizeof(errbuf), "You are only allowed to talk in '%s'", SPAMFILTER_VIRUSCHAN); + *errmsg = errbuf; + return 0; + } + + /* Spamfilter: run on original text, before hooks may transform it + * (e.g. +G censor). This ensures spamfilter catches the actual + * input even if a channel mode would sanitize it for delivery. + */ + if (sendtype != SEND_TYPE_TAGMSG) + { + int spamtype = (sendtype == SEND_TYPE_NOTICE ? SPAMF_CHANNOTICE : SPAMF_CHANMSG); + const char *cmd = sendtype_to_cmd(sendtype); + if (match_spamfilter(client, *msgtext, spamtype, cmd, channel->name, 0, clictx, NULL)) + return 0; + } + member = IsMember(client, channel); lp = find_membership_link(client->user->channel, channel); diff --git a/src/modules/multiline.c b/src/modules/multiline.c new file mode 100644 index 000000000..abc41baba --- /dev/null +++ b/src/modules/multiline.c @@ -0,0 +1,1953 @@ +/* + * IRC - Internet Relay Chat, src/modules/multiline.c + * (C) 2026 Syzop & The UnrealIRCd Team + * + * See file AUTHORS in IRC package for additional names of + * the programmers. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 1, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ + +#include "unrealircd.h" + +ModuleHeader MOD_HEADER + = { + "multiline", + "1.0", + "IRCv3 draft/multiline", + "UnrealIRCd Team", + "unrealircd-6", + }; + +// TODO: history does not work currently with multiline, we should communicate +// the batch stuff via a different hook, and be atomic to include it in history +// This will be done later... + +/* ===================== CONFIGURATION ===================== */ + +struct { + int batch_timeout; +} cfg; + +/* ===================== DATA STRUCTURES ===================== */ + +/** A single line within a multiline batch */ +typedef struct MultilineLine MultilineLine; +struct MultilineLine { + MultilineLine *next; + char *text; /**< Message text for this line (may be empty string for blank lines) */ + int concat; /**< 1 if draft/multiline-concat tag was present */ +}; + +/** State for a locally-initiated multiline batch (one per local client) */ +typedef struct MultilineBatch MultilineBatch; +struct MultilineBatch { + char batch_id[BATCHLEN+1]; /**< Client-chosen batch reference tag */ + char *target; /**< Target channel or nick */ + SendType sendtype; /**< SEND_TYPE_PRIVMSG or SEND_TYPE_NOTICE */ + int sendtype_set; /**< Has sendtype been determined (from first line)? */ + char member_modes[2]; /**< Member mode filter from STATUSMSG prefix (e.g. "o"), or empty string */ + MessageTag *client_mtags; /**< Tags from the opening BATCH command */ + int line_count; + int received_bytes; /**< Total user-sent content bytes (for max-bytes policy) */ + time_t start_time; + int failed; /**< Batch marked as failed — consume remaining lines, send error at BATCH close */ + char *fail_message; /**< FAIL response to send at BATCH close (if failed) */ + char label[256]; /**< Saved label for echo-message + labeled-response interaction */ + /* Buffered lines */ + MultilineLine *lines; + MultilineLine *lines_tail; + MultilineLine *fallback_lines; /**< Cached fallback lines (built once, reused for all non-multiline clients) */ +}; + +/** State for an S2S multiline batch being received from a remote server */ +typedef struct S2SMultilineBatch S2SMultilineBatch; +struct S2SMultilineBatch { + S2SMultilineBatch *prev, *next; + Client *sender; /**< Remote user who initiated */ + Client *direction; /**< Server link it came from */ + char batch_id[BATCHLEN+1]; + char *target; + int is_channel; /**< 1 if target is a channel, 0 if user */ + SendType sendtype; + int sendtype_set; + char member_modes[2]; /**< Member mode filter from STATUSMSG prefix */ + MessageTag *first_mtags; /**< Message tags from the opening BATCH (for relay) */ + int line_count; + int received_bytes; + time_t start_time; + MultilineLine *lines; + MultilineLine *lines_tail; +}; + +/* ===================== FORWARD DECLARATIONS ===================== */ + +/* Module callbacks */ +int multiline_config_test(ConfigFile *cf, ConfigEntry *ce, int type, int *errs); +int multiline_config_run(ConfigFile *cf, ConfigEntry *ce, int type); + +/* Command overrides */ +CMD_OVERRIDE_FUNC(multiline_override_batch); +CMD_OVERRIDE_FUNC(multiline_override_msg); + +/* Message tag handler */ +int multiline_concat_mtag_is_ok(Client *client, const char *name, const char *value); + +/* Capability parameter callback */ +const char *multiline_capability_parameter(Client *client); + +/* ModData free callback */ +void multiline_mdata_free(ModData *m); + +/* Hooks */ +int multiline_close_connection(Client *client); +int multiline_remote_quit(Client *client, MessageTag *mtags, const char *comment); +int multiline_server_quit(Client *client, MessageTag *mtags); + +/* Timer */ +EVENT(multiline_timeout_check); + +/* Core functions */ +static void multiline_deliver(Client *client, MultilineBatch *batch); +static void multiline_deliver_channel(Client *client, MultilineBatch *batch, Channel *channel); +static void multiline_deliver_user(Client *client, MultilineBatch *batch, Client *target); +static void multiline_send_batch_to_client(Client *to, Client *from, MultilineBatch *batch, MessageTag *base_mtags, const char *targetstr, const char *cmd); +static void multiline_send_fallback_to_client(Client *to, Client *from, MultilineBatch *batch, MessageTag *base_mtags, const char *targetstr, const char *cmd); +static void multiline_echo_to_sender(Client *client, MultilineBatch *batch, MessageTag *mtags, const char *targetstr, const char *cmd); +static void multiline_send_s2s_to_direction(Client *direction, Client *from, MultilineBatch *batch, MessageTag *base_mtags, const char *cmd, const char *targetstr, const char *ref); +static void multiline_send_s2s_channel(Client *from, MultilineBatch *batch, Channel *channel, MessageTag *base_mtags, const char *cmd, Client *skip_direction, const char *targetstr); +static void multiline_send_s2s_user(Client *from, MultilineBatch *batch, Client *target, MessageTag *base_mtags, const char *cmd); +static void multiline_deliver_to_local_members(Channel *channel, Client *from, MultilineBatch *batch, MessageTag *mtags, const char *targetstr, const char *cmd, const char *filter_modes, Client *skip, int sendflags); +static void multiline_run_chanmsg_hooks(Client *sender, Channel *channel, int sendflags, const char *member_modes, const char *targetstr, MessageTag *mtags, MultilineLine *lines, SendType sendtype); +static void multiline_run_usermsg_hooks(Client *sender, Client *target, MessageTag *mtags, MultilineLine *lines, SendType sendtype); +static void multiline_fail_batch(Client *client, MultilineBatch *batch, const char *fail_msg); +static void multiline_abort_batch(Client *client, MultilineBatch *batch); +static void multiline_append_line(MultilineLine **lines, MultilineLine **lines_tail, int *line_count, const char *text, int is_concat); +static void multiline_free_lines(MultilineLine *lines); +static void multiline_free_batch(MultilineBatch *batch); +static char *multiline_concat_text(MultilineBatch *batch); + +/* S2S receiving */ +static void multiline_handle_s2s_batch_open(Client *client, MessageTag *recv_mtags, int parc, const char *parv[], int is_channel); +static void multiline_handle_s2s_batch_close(Client *client, const char *batch_id); +static void multiline_handle_s2s_msg(Client *client, MessageTag *recv_mtags, int parc, const char *parv[], SendType sendtype); +static S2SMultilineBatch *multiline_find_s2s_batch(Client *sender, const char *batch_id); +static void multiline_free_s2s_batch(S2SMultilineBatch *s2s); +static void multiline_deliver_s2s_batch(S2SMultilineBatch *s2s); +static void multiline_free_s2s_batches_all(ModData *m); + +/* ===================== VARIABLES ===================== */ + +static long CAP_MULTILINE = 0L; +static long CAP_BATCH = 0L; +#define HasMultiline(c) (HasCapabilityFast(c, CAP_MULTILINE) && HasCapabilityFast(c, CAP_BATCH)) +static ModDataInfo *multiline_md = NULL; +static S2SMultilineBatch *s2s_batches = NULL; + +/* ===================== MODULE LIFECYCLE ===================== */ + +MOD_TEST() +{ + MARK_AS_OFFICIAL_MODULE(modinfo); + HookAdd(modinfo->handle, HOOKTYPE_CONFIGTEST, 0, multiline_config_test); + return MOD_SUCCESS; +} + +MOD_INIT() +{ + ClientCapabilityInfo cap; + ClientCapability *c; + MessageTagHandlerInfo mtag; + ModDataInfo mreq; + + MARK_AS_OFFICIAL_MODULE(modinfo); + + LoadPersistentPointer(modinfo, s2s_batches, multiline_free_s2s_batches_all); + + /* Register draft/multiline capability */ + memset(&cap, 0, sizeof(cap)); + cap.name = "draft/multiline"; + cap.parameter = multiline_capability_parameter; + c = ClientCapabilityAdd(modinfo->handle, &cap, &CAP_MULTILINE); + + /* Register draft/multiline-concat message tag */ + memset(&mtag, 0, sizeof(mtag)); + mtag.name = "draft/multiline-concat"; + mtag.is_ok = multiline_concat_mtag_is_ok; + mtag.clicap_handler = c; + MessageTagHandlerAdd(modinfo->handle, &mtag); + + /* Register ModData for per-client multiline state */ + memset(&mreq, 0, sizeof(mreq)); + mreq.name = "multiline"; + mreq.type = MODDATATYPE_LOCAL_CLIENT; + mreq.free = multiline_mdata_free; + mreq.serialize = NULL; + mreq.unserialize = NULL; + mreq.sync = 0; + multiline_md = ModDataAdd(modinfo->handle, mreq); + + /* Command overrides */ + CommandOverrideAdd(modinfo->handle, "BATCH", 0, multiline_override_batch); + CommandOverrideAdd(modinfo->handle, "PRIVMSG", 0, multiline_override_msg); + CommandOverrideAdd(modinfo->handle, "NOTICE", 0, multiline_override_msg); + + /* Hooks */ + HookAdd(modinfo->handle, HOOKTYPE_CONFIGRUN, 0, multiline_config_run); + HookAdd(modinfo->handle, HOOKTYPE_CLOSE_CONNECTION, 0, multiline_close_connection); + HookAdd(modinfo->handle, HOOKTYPE_REMOTE_QUIT, 0, multiline_remote_quit); + HookAdd(modinfo->handle, HOOKTYPE_SERVER_QUIT, 0, multiline_server_quit); + + /* Timer for batch timeout */ + EventAdd(modinfo->handle, "multiline_timeout", multiline_timeout_check, NULL, 5000, 0); + + /* Set default batch timeout (multiline limits are in FloodSettings via conf.c) */ + cfg.batch_timeout = 15; + + return MOD_SUCCESS; +} + +MOD_LOAD() +{ + CAP_BATCH = ClientCapabilityBit("batch"); + return MOD_SUCCESS; +} + +MOD_UNLOAD() +{ + SavePersistentPointer(modinfo, s2s_batches); + return MOD_SUCCESS; +} + +/* ===================== CAPABILITY ===================== */ + +const char *multiline_capability_parameter(Client *client) +{ + static char buf[128]; + FloodSettings *f = get_floodsettings_for_user(client, FLD_MULTILINE); + + snprintf(buf, sizeof(buf), "max-bytes=%d,max-lines=%d", + (int)f->period[FLD_MULTILINE], + (int)f->limit[FLD_MULTILINE]); + return buf; +} + +/* ===================== MESSAGE TAG ===================== */ + +int multiline_concat_mtag_is_ok(Client *client, const char *name, const char *value) +{ + /* Tag must have no value per spec */ + if (!BadPtr(value)) + return 0; + + if (IsServer(client)) + return 1; + + /* Allow from local users who have an active multiline batch */ + if (MyUser(client)) + { + MultilineBatch *batch = moddata_local_client(client, multiline_md).ptr; + if (batch) + return 1; + } + + return 0; +} + +/* ===================== CONFIGURATION ===================== */ + +int multiline_config_test(ConfigFile *cf, ConfigEntry *ce, int type, int *errs) +{ + int errors = 0; + ConfigEntry *cep; + + if (type != CONFIG_SET) + return 0; + + if (!ce || strcmp(ce->name, "multiline")) + return 0; + + for (cep = ce->items; cep; cep = cep->next) + { + if (!cep->value) + { + config_error("%s:%i: blank set::multiline::%s without value", + cep->file->filename, cep->line_number, cep->name); + errors++; + continue; + } + + if (!strcmp(cep->name, "batch-timeout")) + { + int v = atoi(cep->value); + if (v < 5 || v > 120) + { + config_error("%s:%i: set::multiline::batch-timeout must be between 5 and 120 seconds", + cep->file->filename, cep->line_number); + errors++; + } + } else + { + config_error("%s:%i: unknown directive set::multiline::%s", + cep->file->filename, cep->line_number, cep->name); + errors++; + } + } + + *errs = errors; + return errors ? -1 : 1; +} + +int multiline_config_run(ConfigFile *cf, ConfigEntry *ce, int type) +{ + ConfigEntry *cep; + + if (type != CONFIG_SET) + return 0; + + if (!ce || strcmp(ce->name, "multiline")) + return 0; + + for (cep = ce->items; cep; cep = cep->next) + { + if (!strcmp(cep->name, "batch-timeout")) + cfg.batch_timeout = atoi(cep->value); + } + + return 1; +} + +/* ===================== MODDATA ===================== */ + +void multiline_mdata_free(ModData *m) +{ + MultilineBatch *batch = m->ptr; + if (batch) + { + multiline_free_batch(batch); + m->ptr = NULL; + } +} + +/* ===================== HELPER FUNCTIONS ===================== */ + +/** Find channel for a target that may have a STATUSMSG prefix (e.g. "@#channel") */ +static inline Channel *multiline_find_channel(const char *target) +{ + const char *p = strchr(target, '#'); + return p ? find_channel(p) : NULL; +} + +/** Duplicate a message tag list, excluding a specific tag name. + * @param mtags Source tag list + * @param exclude Tag name to exclude + * @returns New tag list (caller must free with free_message_tags()) + */ +static MessageTag *duplicate_mtags_excluding(MessageTag *mtags, const char *exclude) +{ + MessageTag *out = NULL, *m, *dup; + + for (m = mtags; m; m = m->next) + { + if (!strcmp(m->name, exclude)) + continue; + dup = duplicate_mtag(m); + AddListItem(dup, out); + } + return out; +} + +/** Duplicate a message tag list, excluding tags with MTAG_HANDLER_FLAGS_FIRST_ONLY. + * Used to build the tag set for subsequent fallback lines (lines 2..N), + * where tags like msgid and +draft/reply should not be repeated. + */ +static MessageTag *duplicate_mtags_for_subsequent_lines(MessageTag *mtags) +{ + MessageTag *out = NULL, *m, *dup; + + for (m = mtags; m; m = m->next) + { + MessageTagHandler *handler = MessageTagHandlerFind(m->name); + if (handler && (handler->flags & MTAG_HANDLER_FLAGS_FIRST_ONLY)) + continue; + dup = duplicate_mtag(m); + AddListItem(dup, out); + } + return out; +} + +/** Run HOOKTYPE_CHANMSG per-line for a multiline batch. + * First line carries mtags (incl msgid), subsequent lines don't. + */ +static void multiline_run_chanmsg_hooks(Client *sender, Channel *channel, + int sendflags, const char *member_modes, const char *targetstr, + MessageTag *mtags, MultilineLine *lines, SendType sendtype) +{ + MultilineLine *line; + + echo_message_inhibit = 1; + for (line = lines; line; line = line->next) + { + RunHook(HOOKTYPE_CHANMSG, sender, channel, sendflags, + member_modes, targetstr, + (line == lines) ? mtags : NULL, + line->text, sendtype); + } + echo_message_inhibit = 0; +} + +/** Run HOOKTYPE_USERMSG per-line for a multiline batch. + * First line carries mtags (incl msgid), subsequent lines don't. + */ +static void multiline_run_usermsg_hooks(Client *sender, Client *target, + MessageTag *mtags, MultilineLine *lines, SendType sendtype) +{ + MultilineLine *line; + + echo_message_inhibit = 1; + for (line = lines; line; line = line->next) + { + RunHook(HOOKTYPE_USERMSG, sender, target, + (line == lines) ? mtags : NULL, + line->text, sendtype); + } + echo_message_inhibit = 0; +} + +/** Free a linked list of multiline lines */ +static void multiline_free_lines(MultilineLine *lines) +{ + MultilineLine *l, *l_next; + for (l = lines; l; l = l_next) + { + l_next = l->next; + safe_free(l->text); + safe_free(l); + } +} + +/** Free a local multiline batch (but not the ModData slot itself) */ +static void multiline_free_batch(MultilineBatch *batch) +{ + if (!batch) + return; + safe_free(batch->target); + safe_free(batch->fail_message); + free_message_tags(batch->client_mtags); + multiline_free_lines(batch->lines); + multiline_free_lines(batch->fallback_lines); + safe_free(batch); +} + +/** Append a line to a multiline line list */ +static void multiline_append_line(MultilineLine **lines, MultilineLine **lines_tail, + int *line_count, const char *text, int is_concat) +{ + MultilineLine *line = safe_alloc(sizeof(MultilineLine)); + safe_strdup(line->text, text ? text : ""); + line->concat = is_concat; + line->next = NULL; + + if (*lines_tail) + (*lines_tail)->next = line; + else + *lines = line; + *lines_tail = line; + (*line_count)++; +} + +/** Calculate the multiline batch fake lag in milliseconds. + * Uses the same per-line formula as parse.c with a floor of + * 1 extra line and a hard cap of 15 seconds. + * FIXME: reconsider exact algo + */ +static long calculate_multiline_fakelag(Client *client, MultilineBatch *batch) +{ + FloodSettings *settings; + int lag_penalty, lag_penalty_bytes; + long lag_msec; + MultilineLine *l; + + if (!batch->lines) + return 0; + + settings = get_floodsettings_for_user(client, FLD_LAG_PENALTY); + lag_penalty = settings->period[FLD_LAG_PENALTY]; + lag_penalty_bytes = settings->limit[FLD_LAG_PENALTY]; + if (lag_penalty_bytes < 1) + lag_penalty_bytes = 1; + + lag_msec = lag_penalty; /* floor: 1 extra line */ + for (l = batch->lines; l; l = l->next) + lag_msec += (1 + ((int)strlen(l->text) / lag_penalty_bytes)) * lag_penalty; + if (lag_msec > 15000) + lag_msec = 15000; + return lag_msec; +} + +/** Mark a local multiline batch as failed. + * The batch stays open so remaining lines are silently consumed. + * The error is sent when the client sends BATCH -ref. + * @param fail_msg The FAIL response line (after ": "), e.g. + * "FAIL BATCH MULTILINE_MAX_LINES 5 :Too many lines in batch" + */ +static void multiline_fail_batch(Client *client, MultilineBatch *batch, const char *fail_msg) +{ + if (!batch) + return; + if (batch->failed) + return; /* Already marked, keep first error */ + batch->failed = 1; + safe_strdup(batch->fail_message, fail_msg); +} + +/** Abort a local multiline batch: apply half fake lag penalty and free state. + * Called at BATCH close for failed batches, or for fatal errors where + * the batch state must be freed immediately. + */ +static void multiline_abort_batch(Client *client, MultilineBatch *batch) +{ + if (!batch) + return; + + /* Apply half the batch fake lag penalty for aborted batches */ + add_fake_lag(client, 2000 + calculate_multiline_fakelag(client, batch) / 2); + + multiline_free_batch(batch); + moddata_local_client(client, multiline_md).ptr = NULL; +} + +/** Calculate the number of bytes a line adds to a multiline batch. + * Accounts for the \n separator between non-concat lines. + * If you wonder why this is a separate and surprisingly small function, + * it is because already screwing up twice, where the local-case + * diverted from the remote-case, so hence this helper :D. + */ +static inline int multiline_calc_add_bytes(int text_len, int line_count, int is_concat) +{ + return text_len + (((line_count > 0) && !is_concat) ? 1 : 0); +} + +/** Concatenate all batch lines into a single string (for spamfilter). + * Concat lines are joined without separator, normal lines with \n. + * @returns A dynamically allocated string. Caller must free with safe_free(). + */ +static char *multiline_concat_text(MultilineBatch *batch) +{ + MultilineLine *l; + int bufsize = 1; /* 1 for \0 */ + char *buf; + int pos = 0; + + /* Calculate buffer size from actual line data, since text + * may have been transformed at delivery time (+S, +G, etc.). + */ + for (l = batch->lines; l; l = l->next) + { + if (l != batch->lines && !l->concat) + bufsize++; /* \n separator */ + if (l->text) + bufsize += strlen(l->text); + } + buf = safe_alloc(bufsize); + + /* Now build the big string*/ + for (l = batch->lines; l; l = l->next) + { + if (l != batch->lines && !l->concat) + { + if (pos + 1 >= bufsize) + break; + buf[pos++] = '\n'; + } + if (l->text) + { + int len = strlen(l->text); + if (pos + len >= bufsize) + break; + strlcpy(buf + pos, l->text, bufsize - pos); + pos += len; + } + } + buf[pos] = '\0'; + + return buf; +} + +/** Build fallback text: merge concat lines into their predecessors. + * Returns a list of "logical lines" for fallback delivery. + * Caller must free the returned list with multiline_free_lines(). + */ +static MultilineLine *multiline_build_fallback_lines(MultilineBatch *batch) +{ + MultilineLine *out = NULL, *out_tail = NULL; + MultilineLine *l; + + for (l = batch->lines; l; l = l->next) + { + if (l->concat && out_tail) + { + /* Append to current logical line without separator */ + const char *append = l->text ? l->text : ""; + int newlen = strlen(out_tail->text) + strlen(append) + 1; + char *newtext = safe_alloc(newlen); + strlcpy(newtext, out_tail->text, newlen); + strlcat(newtext, append, newlen); + safe_free(out_tail->text); + out_tail->text = newtext; + } else { + /* Start a new logical line */ + MultilineLine *newline = safe_alloc(sizeof(MultilineLine)); + safe_strdup(newline->text, l->text ? l->text : ""); + newline->concat = 0; + newline->next = NULL; + if (out_tail) + out_tail->next = newline; + else + out = newline; + out_tail = newline; + } + } + return out; +} + +/** Get cached fallback lines, building them on first call */ +static MultilineLine *multiline_get_fallback_lines(MultilineBatch *batch) +{ + if (!batch->fallback_lines) + batch->fallback_lines = multiline_build_fallback_lines(batch); + return batch->fallback_lines; +} + +/** Check if a batch has at least one non-blank line */ +static int multiline_batch_has_content(MultilineBatch *batch) +{ + MultilineLine *l; + + for (l = batch->lines; l; l = l->next) + if (l->text && l->text[0]) + return 1; + return 0; +} + +/* ===================== BATCH COMMAND OVERRIDE ===================== */ + +CMD_OVERRIDE_FUNC(multiline_override_batch) +{ + const char *ref; + MultilineBatch *batch; + + /* Handle S2S multiline batches. + * Both channel and user cases use the same wire format: + * BATCH +ref draft/multiline [extra] + * BATCH -ref + * where is a channel name or user UID. + * We distinguish channel vs user by checking if parv[1] contains '#'. + */ + if (IsServer(client) || !MyConnect(client)) + { + if (parc >= 3 && (parv[2][0] == '+' || parv[2][0] == '-')) + { + int is_channel = (strchr(parv[1], '#') != NULL); + + if (parv[2][0] == '+' && parc >= 4 && !strcmp(parv[3], "draft/multiline")) + { + multiline_handle_s2s_batch_open(client, recv_mtags, parc, parv, is_channel); + return; + } + if (parv[2][0] == '-') + { + S2SMultilineBatch *s2s = multiline_find_s2s_batch(client, parv[2] + 1); + if (s2s) + { + multiline_handle_s2s_batch_close(client, parv[2] + 1); + return; + } + } + } + /* Not a multiline batch - fall through to batch.c */ + CALL_NEXT_COMMAND_OVERRIDE(); + return; + } + + /* Not a local user - fall through */ + if (!MyUser(client)) + { + CALL_NEXT_COMMAND_OVERRIDE(); + return; + } + + if (parc < 2 || BadPtr(parv[1])) + { + CALL_NEXT_COMMAND_OVERRIDE(); + return; + } + + ref = parv[1]; + + /* Opening batch: BATCH +ref draft/multiline */ + if (ref[0] == '+') + { + const char *batch_type; + const char *target; + + ref++; /* skip '+' */ + + if (!valid_batch_reference_tag(ref)) + { + sendto_one(client, NULL, ":%s FAIL BATCH INVALID_REFTAG %s :Invalid batch reference tag", me.name, ref); + return; + } + + if (parc < 4 || BadPtr(parv[2]) || BadPtr(parv[3])) + { + sendto_one(client, NULL, ":%s FAIL BATCH MULTILINE_INVALID :Insufficient parameters", me.name); + return; + } + + batch_type = parv[2]; + target = parv[3]; + + /* Only handle draft/multiline batch type */ + if (strcmp(batch_type, "draft/multiline")) + { + /* Not a multiline batch - fall through */ + CALL_NEXT_COMMAND_OVERRIDE(); + return; + } + + /* Check capabilities */ + if (!HasMultiline(client)) + { + sendto_one(client, NULL, ":%s FAIL BATCH MULTILINE_INVALID :You must negotiate the draft/multiline and batch capabilities", me.name); + return; + } + + /* Check for existing open batch */ + if (moddata_local_client(client, multiline_md).ptr) + { + sendto_one(client, NULL, ":%s FAIL BATCH MULTILINE_INVALID :You already have an open multiline batch", me.name); + return; + } + + /* Validate batch reference length */ + if (strlen(ref) > BATCHLEN || strlen(ref) < 1) + { + sendto_one(client, NULL, ":%s FAIL BATCH MULTILINE_INVALID :Invalid batch reference tag", me.name); + return; + } + + /* Allocate and initialize batch state */ + batch = safe_alloc(sizeof(MultilineBatch)); + strlcpy(batch->batch_id, ref, sizeof(batch->batch_id)); + + /* Parse STATUSMSG prefix from target, modeled on message.c cmd_message() */ + { + const char *p2 = strchr(target, '#'); + if (p2 && (p2 - target > 0)) + { + char prefix_tmp[32]; + char prefix; + Channel *channel; + + strlncpy(prefix_tmp, target, sizeof(prefix_tmp), p2 - target); + prefix = lowest_ranking_prefix(prefix_tmp); + if (!prefix) + { + sendto_one(client, NULL, ":%s FAIL BATCH MULTILINE_INVALID :Invalid prefix", me.name); + safe_free(batch); + return; + } + channel = find_channel(p2); + if (!channel) + { + sendnumeric(client, ERR_NOSUCHNICK, p2); + safe_free(batch); + return; + } + /* STATUSMSG access is checked at delivery time + * (in multiline_deliver_channel) to avoid TOCTOU. + */ + + /* Store normalized prefixed target (e.g., "@#channel") */ + { + char pfixchan[CHANNELLEN + 4]; + snprintf(pfixchan, sizeof(pfixchan), "%c%s", prefix, channel->name); + safe_strdup(batch->target, pfixchan); + } + + /* Store member mode */ + batch->member_modes[0] = prefix_to_mode(prefix); + batch->member_modes[1] = '\0'; + } else { + safe_strdup(batch->target, target); + batch->member_modes[0] = '\0'; + } + } + + batch->sendtype_set = 0; + batch->line_count = 0; + batch->received_bytes = 0; + batch->start_time = TStime(); + batch->lines = NULL; + batch->lines_tail = NULL; + + /* Save client tags from the BATCH command (spec: other client tags go here) */ + batch->client_mtags = duplicate_mtags_excluding(recv_mtags, "batch"); + + /* For echo-message + labeled-response interaction: save the label + * and clear the LR context so lr_post_command won't send a + * premature ACK. The label will be placed on the echo batch's + * BATCH open line at delivery time instead. + * Spec: "Servers MUST only include the label tag on the opening + * BATCH command when replying to a client using echo-message." + */ + batch->label[0] = '\0'; + if (HasCapability(client, "echo-message") && + HasCapability(client, "labeled-response")) + { + MessageTag *mt; + for (mt = recv_mtags; mt; mt = mt->next) + { + if (!strcmp(mt->name, "label") && mt->value) + { + strlcpy(batch->label, mt->value, sizeof(batch->label)); + labeled_response_set_context(NULL); + break; + } + } + } + + moddata_local_client(client, multiline_md).ptr = batch; + return; /* Consumed, don't fall through */ + } + + /* Closing batch: BATCH -ref */ + if (ref[0] == '-') + { + ref++; /* skip '-' */ + + batch = moddata_local_client(client, multiline_md).ptr; + if (!batch || strcmp(batch->batch_id, ref)) + { + sendto_one(client, NULL, ":%s FAIL BATCH MULTILINE_INVALID :No matching open batch", me.name); + return; + } + + /* If the batch was marked as failed during line processing, + * send the deferred error now and clean up. + */ + if (batch->failed) + { + sendto_one(client, NULL, ":%s %s", me.name, batch->fail_message); + multiline_abort_batch(client, batch); + return; + } + + /* Validate: batch must have at least one line */ + if (batch->line_count == 0) + { + sendto_one(client, NULL, ":%s FAIL BATCH MULTILINE_INVALID :Empty batch", me.name); + multiline_abort_batch(client, batch); + return; + } + + /* Validate: batch must not be entirely blank lines */ + if (!multiline_batch_has_content(batch)) + { + sendto_one(client, NULL, ":%s FAIL BATCH MULTILINE_INVALID :Batch consists entirely of blank lines", me.name); + multiline_abort_batch(client, batch); + return; + } + + /* Deliver the batch */ + multiline_deliver(client, batch); + + /* Apply batch fake lag */ + add_fake_lag(client, calculate_multiline_fakelag(client, batch)); + + /* Clean up */ + multiline_free_batch(batch); + moddata_local_client(client, multiline_md).ptr = NULL; + return; + } + + /* Not +/- prefixed, fall through */ + CALL_NEXT_COMMAND_OVERRIDE(); +} + +/* ===================== PRIVMSG/NOTICE OVERRIDE ===================== */ + +CMD_OVERRIDE_FUNC(multiline_override_msg) +{ + MultilineBatch *batch; + MessageTag *m; + FloodSettings *f; + const char *batch_ref = NULL; + Channel *channel; + const char *target; + const char *text; + int is_concat = 0; + int text_len; + int add_bytes; + SendType sendtype; + + /* Only intercept from local users */ + if (!MyUser(client)) + { + /* Check for S2S multiline batch line */ + if (IsServer(client) || !MyConnect(client)) + { + m = find_mtag(recv_mtags, "batch"); + if (m && m->value) + { + S2SMultilineBatch *s2s = multiline_find_s2s_batch(client, m->value); + if (s2s) + { + /* Determine sendtype from command name */ + sendtype = !strcmp(ovr->command->cmd, "NOTICE") ? SEND_TYPE_NOTICE : SEND_TYPE_PRIVMSG; + multiline_handle_s2s_msg(client, recv_mtags, parc, parv, sendtype); + return; + } + } + } + CALL_NEXT_COMMAND_OVERRIDE(); + return; + } + + /* Check for @batch= tag */ + m = find_mtag(recv_mtags, "batch"); + if (!m || !m->value) + { + CALL_NEXT_COMMAND_OVERRIDE(); + return; + } + batch_ref = m->value; + + /* Look up active batch */ + batch = moddata_local_client(client, multiline_md).ptr; + if (!batch || strcmp(batch->batch_id, batch_ref)) + { + /* No matching batch - process as normal message */ + CALL_NEXT_COMMAND_OVERRIDE(); + return; + } + + /* If the batch was already marked as failed, silently consume + * remaining lines. The error will be sent at BATCH close. + */ + if (batch->failed) + { + /* Undo fakelag for discarded lines, but only up to + * max-lines total to prevent flood evasion. + */ + FloodSettings *f = get_floodsettings_for_user(client, FLD_MULTILINE); + int max_lines = f->limit[FLD_MULTILINE]; + batch->failed++; + if (batch->line_count + batch->failed <= max_lines) + subtract_fake_lag(client, clictx->fake_lag_added_msec); + return; + } + + /* We are inside an active multiline batch - buffer this line */ + if (parc < 2 || BadPtr(parv[1])) + { + sendto_one(client, NULL, ":%s FAIL BATCH MULTILINE_INVALID :Insufficient parameters", me.name); + multiline_abort_batch(client, batch); + return; + } + + target = parv[1]; + text = (parc >= 3 && parv[2]) ? parv[2] : ""; + + /* Determine SendType from command name */ + sendtype = !strcmp(ovr->command->cmd, "NOTICE") ? SEND_TYPE_NOTICE : SEND_TYPE_PRIVMSG; + + /* First line sets sendtype, subsequent must match (no mixing PRIVMSG/NOTICE) */ + if (!batch->sendtype_set) + { + batch->sendtype = sendtype; + batch->sendtype_set = 1; + } else if (batch->sendtype != sendtype) { + char buf[512]; + snprintf(buf, sizeof(buf), "FAIL BATCH MULTILINE_INVALID :Cannot mix PRIVMSG and NOTICE in a multiline batch"); + multiline_fail_batch(client, batch, buf); + subtract_fake_lag(client, clictx->fake_lag_added_msec); + return; + } + + /* Validate target matches batch target */ + if (strcasecmp(target, batch->target)) + { + char buf[512]; + snprintf(buf, sizeof(buf), "FAIL BATCH MULTILINE_INVALID_TARGET %s %s :Target mismatch", + batch->target, target); + multiline_fail_batch(client, batch, buf); + subtract_fake_lag(client, clictx->fake_lag_added_msec); + return; + } + + /* Check for draft/multiline-concat tag */ + if (find_mtag(recv_mtags, "draft/multiline-concat")) + is_concat = 1; + + /* Validate: blank line with concat is invalid */ + if (is_concat && (!text || !text[0])) + { + multiline_fail_batch(client, batch, "FAIL BATCH MULTILINE_INVALID :Blank line with concat tag"); + subtract_fake_lag(client, clictx->fake_lag_added_msec); + return; + } + + /* Reject CTCP in multiline batches (including ACTION). + * A batch mixing CTCP and non-CTCP lines has no coherent + * semantics, and even a pure-CTCP batch would behave + * differently for multiline-capable vs fallback recipients. + * Fallback delivery sends each line as an individual PRIVMSG, + * turning one batch into N separate CTCP requests — a flood + * amplification vector triggering auto-replies from each client. + */ + if (text && *text == '\001') + { + multiline_fail_batch(client, batch, "FAIL BATCH MULTILINE_INVALID :CTCP not permitted in multiline batches"); + subtract_fake_lag(client, clictx->fake_lag_added_msec); + return; + } + + /* Check max-lines and max-bytes (per-group limits) */ + if ((f = get_floodsettings_for_user(client, FLD_MULTILINE))) + { + int max_lines = f->limit[FLD_MULTILINE]; + int max_bytes = (int)f->period[FLD_MULTILINE]; + + /* If target is a channel, also respect +f/+F 'm' and 't' limits */ + channel = multiline_find_channel(batch->target); + if (channel) + max_lines = MIN(max_lines, get_floodprot_channel_max_lines(channel)); + + if (batch->line_count >= max_lines) + { + char buf[512]; + snprintf(buf, sizeof(buf), "FAIL BATCH MULTILINE_MAX_LINES %d :Too many lines in batch", max_lines); + multiline_fail_batch(client, batch, buf); + subtract_fake_lag(client, clictx->fake_lag_added_msec); + return; + } + + /* Check max-bytes (text length + 1 for newline separator, except for concat and first line) */ + text_len = text ? strlen(text) : 0; + add_bytes = multiline_calc_add_bytes(text_len, batch->line_count, is_concat); + + if (batch->received_bytes + add_bytes > max_bytes) + { + char buf[512]; + snprintf(buf, sizeof(buf), "FAIL BATCH MULTILINE_MAX_BYTES %d :Too many bytes in batch", max_bytes); + multiline_fail_batch(client, batch, buf); + subtract_fake_lag(client, clictx->fake_lag_added_msec); + return; + } + batch->received_bytes += add_bytes; + } + + /* Permission checks (can_send_to_channel / can_send_to_user) and text + * transformations (+S, +G, etc.) are intentionally NOT done here during + * buffering. They are deferred to delivery time (multiline_deliver_channel + * / multiline_deliver_user) to avoid TOCTOU issues: channel modes, bans, + * or user modes could change between buffering and delivery. + * Raw (untransformed) text is buffered here. + */ + + /* Buffer the line */ + multiline_append_line(&batch->lines, &batch->lines_tail, &batch->line_count, text, is_concat); + + /* Undo the fake lag that parse_addlag() added for this buffered line, + * the batch formula at BATCH close will apply the correct lag instead. + */ + subtract_fake_lag(client, clictx->fake_lag_added_msec); + + /* Line buffered - do NOT call CALL_NEXT_COMMAND_OVERRIDE() */ +} + +/* ===================== BATCH DELIVERY ===================== */ + +/** Deliver a completed multiline batch */ +static void multiline_deliver(Client *client, MultilineBatch *batch) +{ + Channel *channel; + Client *target; + const char *text, *errmsg; + char *concat_text; + + /* Force labeled-response (like message.c does) */ + labeled_response_force = 1; + + /* Resolve target (channel, or NULL for user targets / vanished channels) */ + channel = multiline_find_channel(batch->target); + if (channel) + { + multiline_deliver_channel(client, batch, channel); + } else if (strchr(batch->target, '#')) { + /* Target had a '#' but channel not found (parted/destroyed during batch) */ + sendnumeric(client, ERR_NOSUCHNICK, batch->target); + return; + } else { + target = hash_find_nickatserver(batch->target, NULL); + if (!target) + { + if (SERVICES_NAME) + { + char *server = strchr(batch->target, '@'); + if (server && strncasecmp(server + 1, SERVICES_NAME, strlen(SERVICES_NAME)) == 0) + { + sendnumeric(client, ERR_SERVICESDOWN, batch->target); + return; + } + } + sendnumeric(client, ERR_NOSUCHNICK, batch->target); + return; + } + multiline_deliver_user(client, batch, target); + } +} + +/** Deliver multiline batch to a channel */ +static void multiline_deliver_channel(Client *client, MultilineBatch *batch, Channel *channel) +{ + char *concat_text; + const char *cmd = sendtype_to_cmd(batch->sendtype); + MessageTag *mtags = NULL; + int sendflags = SEND_ALL; + MultilineLine *line; + char expanded_modes[64]; + const char *filter_modes = NULL; + + /* Run permission checks and text transformations at delivery time + * (not during buffering) to avoid TOCTOU: channel modes, bans, + * or membership could change between buffering and batch close. + */ + if (MyUser(client) && !IsULine(client)) + { + for (line = batch->lines; line; line = line->next) + { + TextAnalysis ta; + ClientContext delivery_clictx; + const char *check_text; + const char *check_errmsg = NULL; + + if (!line->text || !*line->text) + continue; /* blank lines are valid paragraph breaks */ + + memset(&ta, 0, sizeof(ta)); + memset(&delivery_clictx, 0, sizeof(delivery_clictx)); + delivery_clictx.textanalysis = &ta; + RunHook(HOOKTYPE_ANALYZE_TEXT, client, line->text, &ta); + + check_text = line->text; + if (!can_send_to_channel(client, channel, &check_text, &check_errmsg, batch->sendtype, &delivery_clictx)) + { + if (IsDead(client)) + return; + return; /* Batch rejected */ + } + /* Apply transformed text (e.g. +S stripped colors, +G censored) */ + if (check_text != line->text) + { + safe_free(line->text); + safe_strdup(line->text, check_text); + } + } + + /* Re-check STATUSMSG access (user may have lost op/voice during batch) */ + if (batch->member_modes[0]) + { + if (!op_can_override("channel:override:message:prefix", client, channel, NULL)) + { + Membership *lp = find_membership_link(client->user->channel, channel); + if (!lp || !check_channel_access_membership(lp, "vhoaq")) + { + sendnumeric(client, ERR_CHANOPRIVSNEEDED, channel->name); + return; + } + } + } + } + + /* Expand member_modes to include equal-or-higher ranks for STATUSMSG filtering */ + if (batch->member_modes[0]) + { + channel_member_modes_generate_equal_or_greater(batch->member_modes, expanded_modes, sizeof(expanded_modes)); + filter_modes = expanded_modes; + } + + /* Spamfilter on concatenated text */ + if (MyUser(client)) + { + int spamtype = (batch->sendtype == SEND_TYPE_NOTICE ? SPAMF_CHANNOTICE : SPAMF_CHANMSG); + concat_text = multiline_concat_text(batch); + if (match_spamfilter(client, concat_text, spamtype, cmd, channel->name, 0, NULL, NULL)) + { + safe_free(concat_text); + return; + } + safe_free(concat_text); + } + + /* Generate outgoing message tags */ + new_message(client, batch->client_mtags, &mtags); + + /* Compute sendflags */ + if (batch->lines && batch->lines->text && !strchr(CHANCMDPFX, batch->lines->text[0])) + sendflags |= SKIP_DEAF; + + /* Hook: PRE_CHANMSG (per-line, matching single-message behavior) */ + for (line = batch->lines; line; line = line->next) + RunHook(HOOKTYPE_PRE_CHANMSG, client, channel, &mtags, line->text, batch->sendtype); + + /* Deliver to local channel members */ + multiline_deliver_to_local_members(channel, client, batch, mtags, + batch->target, cmd, filter_modes, client, sendflags); + + /* Echo-message for sender */ + multiline_echo_to_sender(client, batch, mtags, batch->target, cmd); + + /* S2S relay */ + multiline_send_s2s_channel(client, batch, channel, mtags, cmd, client->direction, batch->target); + + /* FIXME: history module needs a dedicated hook (or new approach) + * to store and replay multiline batches properly, as per-line + * hooks lose the multiline structure. + */ + multiline_run_chanmsg_hooks(client, channel, sendflags, + batch->member_modes[0] ? batch->member_modes : NULL, + batch->target, mtags, batch->lines, batch->sendtype); + + free_message_tags(mtags); +} + +/** Deliver multiline batch to a user */ +static void multiline_deliver_user(Client *client, MultilineBatch *batch, Client *target) +{ + char *concat_text; + const char *cmd = sendtype_to_cmd(batch->sendtype); + MessageTag *mtags = NULL; + + /* Run permission checks and text transformations at delivery time + * (not during buffering) to avoid TOCTOU: user modes or target + * identity could change between buffering and batch close. + */ + if (MyUser(client) && !IsULine(client)) + { + MultilineLine *line; + for (line = batch->lines; line; line = line->next) + { + TextAnalysis ta; + ClientContext delivery_clictx; + const char *check_text; + const char *check_errmsg = NULL; + + if (!line->text || !*line->text) + continue; + + memset(&ta, 0, sizeof(ta)); + memset(&delivery_clictx, 0, sizeof(delivery_clictx)); + delivery_clictx.textanalysis = &ta; + RunHook(HOOKTYPE_ANALYZE_TEXT, client, line->text, &ta); + + check_text = line->text; + if (!can_send_to_user(client, target, &check_text, &check_errmsg, batch->sendtype, &delivery_clictx, 0)) + { + if (IsDead(client)) + return; + return; /* Batch rejected */ + } + if (check_text != line->text) + { + safe_free(line->text); + safe_strdup(line->text, check_text); + } + } + } + + /* Spamfilter on concatenated text (not per-line, to catch + * patterns spanning multiple lines and match channel behavior). + */ + if (MyUser(client) && (batch->sendtype != SEND_TYPE_TAGMSG)) + { + int spamtype = (batch->sendtype == SEND_TYPE_NOTICE ? SPAMF_USERNOTICE : SPAMF_USERMSG); + concat_text = multiline_concat_text(batch); + if (match_spamfilter(client, concat_text, spamtype, cmd, target->name, 0, NULL, NULL)) + { + safe_free(concat_text); + return; + } + safe_free(concat_text); + } + + /* Inform sender of away status */ + if (batch->sendtype == SEND_TYPE_PRIVMSG && MyConnect(client) && target->user && target->user->away) + sendnumeric(client, RPL_AWAY, target->name, target->user->away); + + /* Generate outgoing message tags */ + new_message(client, batch->client_mtags, &mtags); + + labeled_response_inhibit = 1; + + if (MyUser(target)) + { + /* Target is local */ + if (HasMultiline(target)) + { + multiline_send_batch_to_client(target, client, batch, mtags, target->name, cmd); + } else { + multiline_send_fallback_to_client(target, client, batch, mtags, target->name, cmd); + } + } else { + /* Target is remote - relay as S2S batch */ + multiline_send_s2s_user(client, batch, target, mtags, cmd); + } + + labeled_response_inhibit = 0; + + /* Echo-message for sender */ + multiline_echo_to_sender(client, batch, mtags, target->name, cmd); + + multiline_run_usermsg_hooks(client, target, mtags, batch->lines, batch->sendtype); + + free_message_tags(mtags); +} + +/** Echo-message: send the multiline batch back to the sender, with label if applicable */ +static void multiline_echo_to_sender(Client *client, MultilineBatch *batch, + MessageTag *mtags, const char *targetstr, const char *cmd) +{ + if (!MyUser(client) || !HasCapability(client, "echo-message")) + return; + + MessageTag label_mtag; + MessageTag *echo_mtags = mtags; + + /* labeled-response: include label on the echo's BATCH open */ + if (batch->label[0]) + { + memset(&label_mtag, 0, sizeof(label_mtag)); + label_mtag.name = "label"; + label_mtag.value = batch->label; + label_mtag.next = mtags; + echo_mtags = &label_mtag; + } + + if (HasMultiline(client)) + { + multiline_send_batch_to_client(client, client, batch, echo_mtags, targetstr, cmd); + } else { + multiline_send_fallback_to_client(client, client, batch, echo_mtags, targetstr, cmd); + } +} + +/* ===================== SENDING HELPERS ===================== */ + +/** Send a multiline batch to a local client that supports draft/multiline */ +static void multiline_send_batch_to_client(Client *to, Client *from, MultilineBatch *batch, + MessageTag *base_mtags, const char *targetstr, const char *cmd) +{ + char server_batch_id[BATCHLEN+1]; + MultilineLine *l; + MessageTag *m; + + generate_batch_id(server_batch_id); + + /* Send BATCH open with base_mtags (msgid, time, etc.) */ + sendto_prefix_one(to, from, base_mtags, + ":%s BATCH +%s draft/multiline %s", + from->name, server_batch_id, targetstr); + + /* Send each line */ + for (l = batch->lines; l; l = l->next) + { + /* Build mtags with @batch= and optionally ;draft/multiline-concat */ + MessageTag batch_tag; + MessageTag concat_tag; + MessageTag *line_mtags = NULL; + + memset(&batch_tag, 0, sizeof(batch_tag)); + batch_tag.name = "batch"; + batch_tag.value = server_batch_id; + + if (l->concat) + { + memset(&concat_tag, 0, sizeof(concat_tag)); + concat_tag.name = "draft/multiline-concat"; + concat_tag.value = NULL; + concat_tag.next = NULL; + batch_tag.next = &concat_tag; + } + line_mtags = &batch_tag; + + sendto_prefix_one(to, from, line_mtags, + ":%s %s %s :%s", + from->name, cmd, targetstr, l->text ? l->text : ""); + } + + /* Send BATCH close */ + sendto_prefix_one(to, from, NULL, ":%s BATCH -%s", from->name, server_batch_id); +} + +/** Send multiline as individual fallback lines to a local client */ +static void multiline_send_fallback_to_client(Client *to, Client *from, MultilineBatch *batch, + MessageTag *base_mtags, const char *targetstr, const char *cmd) +{ + MultilineLine *fallback_lines, *l; + MessageTag *rest_mtags; + int first = 1; + + fallback_lines = multiline_get_fallback_lines(batch); + + /* Build rest_mtags: base_mtags minus msgid. + * Spec: msgid MUST only be on the first line, but other tags + * (time, account, etc.) MAY be on subsequent lines. + */ + rest_mtags = duplicate_mtags_for_subsequent_lines(base_mtags); + + for (l = fallback_lines; l; l = l->next) + { + /* Spec: "Servers MUST NOT send blank lines to clients + * that have not negotiated the multiline capability." + */ + if (!l->text || !l->text[0]) + continue; + + if (first) + { + /* First non-blank line gets full mtags (msgid, time, account, etc.) */ + sendto_prefix_one(to, from, base_mtags, + ":%s %s %s :%s", + from->name, cmd, targetstr, l->text); + first = 0; + } else { + /* Subsequent lines get all tags except msgid */ + sendto_prefix_one(to, from, rest_mtags, + ":%s %s %s :%s", + from->name, cmd, targetstr, l->text); + } + } + + free_message_tags(rest_mtags); +} + +/** Deliver multiline batch to local channel members. + * Iterates local_members, applies STATUSMSG filtering, skips sender and deaf + * as requested, and dispatches via batch or fallback depending on capability. + * @param skip Client to skip (sender for local delivery, NULL for S2S) + * @param sendflags Send flags (SKIP_DEAF etc.), 0 if not applicable + */ +static void multiline_deliver_to_local_members(Channel *channel, Client *from, + MultilineBatch *batch, MessageTag *mtags, const char *targetstr, + const char *cmd, const char *filter_modes, Client *skip, int sendflags) +{ + LocalMember *lm; + + for (lm = channel->local_members; lm; lm = lm->next) + { + Client *acptr = lm->ptr->client; + + if (acptr == skip) + continue; + + if (IsDeaf(acptr) && (sendflags & SKIP_DEAF)) + continue; + + if (filter_modes && !check_channel_access_member(lm->ptr, filter_modes)) + continue; + + if (HasMultiline(acptr)) + multiline_send_batch_to_client(acptr, from, batch, mtags, targetstr, cmd); + else + multiline_send_fallback_to_client(acptr, from, batch, mtags, targetstr, cmd); + } +} + +/** Send a multiline batch (BATCH open + lines + BATCH close) to a single server direction */ +static void multiline_send_s2s_to_direction(Client *direction, Client *from, + MultilineBatch *batch, MessageTag *base_mtags, const char *cmd, + const char *targetstr, const char *ref) +{ + MultilineLine *l; + int first; + + sendto_one(direction, NULL, + ":%s BATCH %s +%s draft/multiline", + from->id, targetstr, ref); + + first = 1; + for (l = batch->lines; l; l = l->next) + { + MessageTag batch_tag; + MessageTag concat_tag; + + memset(&batch_tag, 0, sizeof(batch_tag)); + batch_tag.name = "batch"; + batch_tag.value = (char *)ref; + + if (l->concat) + { + memset(&concat_tag, 0, sizeof(concat_tag)); + concat_tag.name = "draft/multiline-concat"; + concat_tag.value = NULL; + concat_tag.next = NULL; + batch_tag.next = &concat_tag; + } + + if (first) + { + /* First line carries base_mtags plus batch tag */ + MessageTag *combined = NULL; + MessageTag *dup; + MessageTag *mtag_iter; + + for (mtag_iter = base_mtags; mtag_iter; mtag_iter = mtag_iter->next) + { + dup = duplicate_mtag(mtag_iter); + AddListItem(dup, combined); + } + dup = duplicate_mtag(&batch_tag); + if (l->concat) + { + MessageTag *dup_concat = safe_alloc(sizeof(MessageTag)); + safe_strdup(dup_concat->name, "draft/multiline-concat"); + AddListItem(dup_concat, combined); + } + AddListItem(dup, combined); + + sendto_prefix_one(direction, from, combined, + ":%s %s %s :%s", + from->id, cmd, targetstr, l->text ? l->text : ""); + free_message_tags(combined); + first = 0; + } else { + sendto_prefix_one(direction, from, &batch_tag, + ":%s %s %s :%s", + from->id, cmd, targetstr, l->text ? l->text : ""); + } + } + + sendto_one(direction, NULL, + ":%s BATCH %s -%s", + from->id, targetstr, ref); +} + +/** Send multiline batch to remote servers (channel target) */ +static void multiline_send_s2s_channel(Client *from, MultilineBatch *batch, Channel *channel, + MessageTag *base_mtags, const char *cmd, Client *skip_direction, const char *targetstr) +{ + char ref[BATCHLEN+1]; + Member *lp; + Client *acptr; + char expanded_modes[64]; + const char *filter_modes = NULL; + + generate_batch_id(ref); + + /* Expand member_modes for STATUSMSG filtering */ + if (batch->member_modes[0]) + { + channel_member_modes_generate_equal_or_greater(batch->member_modes, expanded_modes, sizeof(expanded_modes)); + filter_modes = expanded_modes; + } + + /* Track which server directions we've already sent to */ + ++current_serial; + + for (lp = channel->members; lp; lp = lp->next) + { + acptr = lp->client; + + if (MyUser(acptr)) + continue; /* Already handled locally */ + + if (acptr->direction == skip_direction) + continue; + + /* STATUSMSG filtering: only relay to directions with matching members */ + if (filter_modes && !check_channel_access_member(lp, filter_modes)) + continue; + + if (acptr->direction->local->serial == current_serial) + continue; /* Already sent to this direction */ + + acptr->direction->local->serial = current_serial; + + multiline_send_s2s_to_direction(acptr->direction, from, batch, base_mtags, cmd, targetstr, ref); + } + + /* Also handle broadcast-channel-messages for +H channels etc. */ + if ((iConf.broadcast_channel_messages == BROADCAST_CHANNEL_MESSAGES_ALWAYS) || + ((iConf.broadcast_channel_messages == BROADCAST_CHANNEL_MESSAGES_AUTO) && has_channel_mode(channel, 'H'))) + { + list_for_each_entry(acptr, &server_list, special_node) + { + if (acptr->direction == skip_direction) + continue; + if (acptr->direction->local->serial == current_serial) + continue; + acptr->direction->local->serial = current_serial; + + multiline_send_s2s_to_direction(acptr->direction, from, batch, base_mtags, cmd, targetstr, ref); + } + } +} + +/** Send multiline batch to remote server (user-to-user target) */ +static void multiline_send_s2s_user(Client *from, MultilineBatch *batch, Client *target, + MessageTag *base_mtags, const char *cmd) +{ + char ref[BATCHLEN+1]; + + generate_batch_id(ref); + multiline_send_s2s_to_direction(target->direction, from, batch, base_mtags, cmd, target->id, ref); +} + +/* ===================== S2S RECEIVING ===================== */ + +/** Find an active S2S batch by sender and batch_id */ +static S2SMultilineBatch *multiline_find_s2s_batch(Client *sender, const char *batch_id) +{ + S2SMultilineBatch *s2s; + + for (s2s = s2s_batches; s2s; s2s = s2s->next) + { + if (s2s->sender == sender && !strcmp(s2s->batch_id, batch_id)) + return s2s; + } + return NULL; +} + +/** Free an S2S batch and remove from the global list */ +static void multiline_free_s2s_batch(S2SMultilineBatch *s2s) +{ + if (!s2s) + return; + DelListItem(s2s, s2s_batches); + safe_free(s2s->target); + free_message_tags(s2s->first_mtags); + multiline_free_lines(s2s->lines); + safe_free(s2s); +} + +/** Free all S2S batches (called when module is permanently removed) */ +static void multiline_free_s2s_batches_all(ModData *m) +{ + S2SMultilineBatch *s2s, *s2s_next; + + if (!m->ptr) + return; + + s2s_batches = m->ptr; + for (s2s = s2s_batches; s2s; s2s = s2s_next) + { + s2s_next = s2s->next; + multiline_free_s2s_batch(s2s); + } + s2s_batches = NULL; + m->ptr = NULL; +} + +/** Handle an incoming S2S multiline batch open */ +static void multiline_handle_s2s_batch_open(Client *client, MessageTag *recv_mtags, + int parc, const char *parv[], int is_channel) +{ + S2SMultilineBatch *s2s; + const char *ref; + const char *target; + + /* BATCH +ref draft/multiline */ + target = parv[1]; + ref = parv[2] + 1; /* skip '+' */ + + /* Check for duplicate */ + if (multiline_find_s2s_batch(client, ref)) + return; /* Duplicate, ignore */ + + s2s = safe_alloc(sizeof(S2SMultilineBatch)); + strlcpy(s2s->batch_id, ref, sizeof(s2s->batch_id)); + safe_strdup(s2s->target, target); + s2s->is_channel = is_channel; + + /* Parse STATUSMSG prefix from target (e.g., "@#channel") */ + s2s->member_modes[0] = '\0'; + if (is_channel) + { + const char *p2 = strchr(target, '#'); + if (p2 && (p2 - target > 0)) + { + char prefix_tmp[32]; + char prefix; + strlncpy(prefix_tmp, target, sizeof(prefix_tmp), p2 - target); + prefix = lowest_ranking_prefix(prefix_tmp); + if (prefix) + { + s2s->member_modes[0] = prefix_to_mode(prefix); + s2s->member_modes[1] = '\0'; + } + } + } + + s2s->sender = client; + s2s->direction = client->direction; + s2s->sendtype_set = 0; + s2s->line_count = 0; + s2s->received_bytes = 0; + s2s->start_time = TStime(); + s2s->lines = NULL; + s2s->lines_tail = NULL; + s2s->first_mtags = NULL; + + /* Save message tags from the BATCH open (for relay) */ + s2s->first_mtags = duplicate_mtags_excluding(recv_mtags, "batch"); + + AddListItem(s2s, s2s_batches); +} + +/** Handle a line within an S2S multiline batch */ +static void multiline_handle_s2s_msg(Client *client, MessageTag *recv_mtags, + int parc, const char *parv[], SendType sendtype) +{ + MessageTag *m; + S2SMultilineBatch *s2s; + const char *text; + int is_concat = 0; + + m = find_mtag(recv_mtags, "batch"); + if (!m || !m->value) + return; + + s2s = multiline_find_s2s_batch(client, m->value); + if (!s2s) + return; + + /* Set or validate sendtype */ + if (!s2s->sendtype_set) + { + s2s->sendtype = sendtype; + s2s->sendtype_set = 1; + } else if (s2s->sendtype != sendtype) { + /* Mismatch - discard batch */ + multiline_free_s2s_batch(s2s); + return; + } + + text = (parc >= 3 && parv[2]) ? parv[2] : ""; + + /* Check for concat tag */ + if (find_mtag(recv_mtags, "draft/multiline-concat")) + is_concat = 1; + + /* Check limits (use hardcoded max, the originating server already enforces per-user limits) */ + { + int add_bytes = multiline_calc_add_bytes(strlen(text), s2s->line_count, is_concat); + + if ((s2s->line_count >= MULTILINE_MAX_CONFIGURABLE_LINES) || + (s2s->received_bytes + add_bytes > MULTILINE_MAX_CONFIGURABLE_BYTES)) + { + /* Over limit - discard batch */ + multiline_free_s2s_batch(s2s); + return; + } + s2s->received_bytes += add_bytes; + } + + /* Save first line's mtags for relay (msgid, time, etc.) */ + if (s2s->line_count == 0 && !s2s->first_mtags) + { + MessageTag *mtag_iter; + for (mtag_iter = recv_mtags; mtag_iter; mtag_iter = mtag_iter->next) + { + if (!strcmp(mtag_iter->name, "batch")) + continue; + if (!strcmp(mtag_iter->name, "draft/multiline-concat")) + continue; + MessageTag *dup = duplicate_mtag(mtag_iter); + AddListItem(dup, s2s->first_mtags); + } + } + + /* Buffer the line */ + multiline_append_line(&s2s->lines, &s2s->lines_tail, &s2s->line_count, text, is_concat); +} + +/** Handle an S2S multiline batch close */ +static void multiline_handle_s2s_batch_close(Client *client, const char *batch_id) +{ + S2SMultilineBatch *s2s = multiline_find_s2s_batch(client, batch_id); + if (!s2s) + return; + + if (s2s->line_count == 0) + { + multiline_free_s2s_batch(s2s); + return; + } + + multiline_deliver_s2s_batch(s2s); + multiline_free_s2s_batch(s2s); +} + +/** Deliver a completed S2S multiline batch to local members and relay further */ +static void multiline_deliver_s2s_batch(S2SMultilineBatch *s2s) +{ + const char *cmd = sendtype_to_cmd(s2s->sendtype); + MessageTag *mtags = NULL; + MultilineBatch *batch = safe_alloc(sizeof(MultilineBatch)); + + /* Build a temporary MultilineBatch struct for the sending helpers */ + strlcpy(batch->batch_id, s2s->batch_id, sizeof(batch->batch_id)); + batch->target = s2s->target; + batch->sendtype = s2s->sendtype; + strlcpy(batch->member_modes, s2s->member_modes, sizeof(batch->member_modes)); + batch->lines = s2s->lines; + batch->lines_tail = s2s->lines_tail; + batch->line_count = s2s->line_count; + batch->received_bytes = s2s->received_bytes; + + if (s2s->is_channel) + { + Channel *channel = multiline_find_channel(s2s->target); + char expanded_modes[64]; + const char *filter_modes = NULL; + + if (channel) + { + /* Expand member_modes for STATUSMSG filtering */ + if (s2s->member_modes[0]) + { + channel_member_modes_generate_equal_or_greater(s2s->member_modes, expanded_modes, sizeof(expanded_modes)); + filter_modes = expanded_modes; + } + + /* Generate outgoing mtags from the first line's tags */ + new_message(s2s->sender, s2s->first_mtags, &mtags); + + /* Deliver to local members */ + multiline_deliver_to_local_members(channel, s2s->sender, batch, mtags, + s2s->target, cmd, filter_modes, NULL, 0); + + /* Relay to further server directions (excluding where it came from) */ + multiline_send_s2s_channel(s2s->sender, batch, channel, mtags, cmd, s2s->direction, s2s->target); + + multiline_run_chanmsg_hooks(s2s->sender, channel, SEND_ALL, + s2s->member_modes[0] ? s2s->member_modes : NULL, + s2s->target, mtags, s2s->lines, s2s->sendtype); + } + } else { + /* User-to-user */ + Client *target = find_client(s2s->target, NULL); + + if (target) + { + new_message(s2s->sender, s2s->first_mtags, &mtags); + + if (MyUser(target)) + { + if (HasMultiline(target)) + multiline_send_batch_to_client(target, s2s->sender, batch, mtags, target->name, cmd); + else + multiline_send_fallback_to_client(target, s2s->sender, batch, mtags, target->name, cmd); + } else { + /* Relay further */ + multiline_send_s2s_user(s2s->sender, batch, target, mtags, cmd); + } + + multiline_run_usermsg_hooks(s2s->sender, target, mtags, s2s->lines, s2s->sendtype); + } + } + + /* Clean up - NULL out borrowed pointers before freeing */ + batch->target = NULL; + batch->lines = NULL; + batch->lines_tail = NULL; + multiline_free_batch(batch); + free_message_tags(mtags); +} + +/* ===================== TIMEOUT AND CLEANUP ===================== */ + +/** Periodic timer: check for timed-out multiline batches */ +EVENT(multiline_timeout_check) +{ + Client *client; + S2SMultilineBatch *s2s, *s2s_next; + + /* Check local client batches */ + list_for_each_entry(client, &lclient_list, lclient_node) + { + MultilineBatch *batch; + + if (!IsUser(client)) + continue; + + batch = moddata_local_client(client, multiline_md).ptr; + if (batch && (TStime() - batch->start_time > cfg.batch_timeout)) + { + sendto_one(client, NULL, ":%s FAIL BATCH TIMEOUT %s :Batch timed out", me.name, batch->batch_id); + multiline_abort_batch(client, batch); + } + } + + /* Check S2S batches */ + for (s2s = s2s_batches; s2s; s2s = s2s_next) + { + s2s_next = s2s->next; + if (TStime() - s2s->start_time > cfg.batch_timeout) + { + multiline_free_s2s_batch(s2s); + } + } +} + +/** Hook: local client disconnecting */ +int multiline_close_connection(Client *client) +{ + /* ModData free callback handles cleanup, but we might need + * additional cleanup here in the future. + */ + return 0; +} + +/** Hook: remote user quitting */ +int multiline_remote_quit(Client *client, MessageTag *mtags, const char *comment) +{ + S2SMultilineBatch *s2s, *s2s_next; + + for (s2s = s2s_batches; s2s; s2s = s2s_next) + { + s2s_next = s2s->next; + if (s2s->sender == client) + multiline_free_s2s_batch(s2s); + } + return 0; +} + +/** Hook: server disconnecting */ +int multiline_server_quit(Client *client, MessageTag *mtags) +{ + S2SMultilineBatch *s2s, *s2s_next; + + for (s2s = s2s_batches; s2s; s2s = s2s_next) + { + s2s_next = s2s->next; + if (s2s->direction == client) + multiline_free_s2s_batch(s2s); + } + return 0; +} diff --git a/src/modules/reply-tag.c b/src/modules/reply-tag.c index ef9e295dd..0ec51d007 100644 --- a/src/modules/reply-tag.c +++ b/src/modules/reply-tag.c @@ -53,7 +53,7 @@ MOD_INIT() memset(&mtag, 0, sizeof(mtag)); mtag.name = "+draft/reply"; mtag.is_ok = replytag_mtag_is_ok; - mtag.flags = MTAG_HANDLER_FLAGS_NO_CAP_NEEDED; + mtag.flags = MTAG_HANDLER_FLAGS_NO_CAP_NEEDED | MTAG_HANDLER_FLAGS_FIRST_ONLY; MessageTagHandlerAdd(modinfo->handle, &mtag); HookAddVoid(modinfo->handle, HOOKTYPE_NEW_MESSAGE, 0, mtag_add_replytag); diff --git a/src/modules/stats.c b/src/modules/stats.c index 9b91bdfa4..63a47d6fd 100644 --- a/src/modules/stats.c +++ b/src/modules/stats.c @@ -753,6 +753,13 @@ static void stats_set_anti_flood(Client *client, FloodSettings *f) f->name, f->limit[i] == INT_MAX ? 0 : (int)f->limit[i]); } else + if (i == FLD_MULTILINE) + { + sendtxtnumeric(client, "anti-flood::%s::multiline::max-lines: %d", + f->name, (int)f->limit[i]); + sendtxtnumeric(client, "anti-flood::%s::multiline::max-bytes: %d", + f->name, (int)f->period[i]); + } else { sendtxtnumeric(client, "anti-flood::%s::%s: %d per %s", f->name, floodoption_names[i], diff --git a/src/modules/tkl.c b/src/modules/tkl.c index ae3e8de91..38e7a7059 100644 --- a/src/modules/tkl.c +++ b/src/modules/tkl.c @@ -5490,7 +5490,7 @@ int _match_spamfilter(Client *client, const char *str_in, int target, const char TKL *winner_tkl = NULL; const char *str; const char *str_deconfused = NULL; - char deconfused[512]; + char deconfused[8192]; int ret = -1; char *reason = NULL; #ifdef SPAMFILTER_DETECTSLOW diff --git a/src/user.c b/src/user.c index 34a1adabc..d4ed30cbe 100644 --- a/src/user.c +++ b/src/user.c @@ -51,6 +51,13 @@ MODVAR int labeled_response_force = 0; */ MODVAR int labeled_response_inhibit_end = 0; +/* FIXME: in a future major release that allows module API changes, + * add sendflags to HOOKTYPE_USERMSG and use a SKIP_ECHO sendflags + * flag instead of this global. See HOOKTYPE_CHANMSG which already + * has sendflags. + */ +MODVAR int echo_message_inhibit = 0; + /** Set to 1 if an UTF8 incompatible nick character set is in use */ MODVAR int non_utf8_nick_chars_in_use = 0; @@ -941,7 +948,7 @@ MODVAR const char *floodoption_names[] = { "max-concurrent-conversations", "lag-penalty", "vhost-flood", - "max-channels-per-user", + "multiline", NULL };