1
0
mirror of https://github.com/unrealircd/unrealircd.git synced 2026-07-04 13:13:13 +02:00

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.
This commit is contained in:
Bram Matthys
2026-03-29 19:30:45 +02:00
parent 8bfc599697
commit b0dba4bede
18 changed files with 2205 additions and 81 deletions
+1 -1
View File
@@ -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 \
+24 -2
View File
@@ -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;
}
+21
View File
@@ -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;
+4
View File
@@ -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)
+1 -1
View File
@@ -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);
+34 -20
View File
@@ -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);
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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);
+7
View File
@@ -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],
+1 -1
View File
@@ -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