/* * Copyright (C) 2014-2015 J-P Nurmi * Copyright (C) 2017-2018, 2021 Vladimir Panteleev and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ #include #include #include #include #include #include #include #include #include #if (VERSION_MAJOR < 1) || (VERSION_MAJOR == 1 && VERSION_MINOR < 6) #error The clientbuffer module requires ZNC version 1.6.0 or later. #endif #if (VERSION_MAJOR > 1) || (VERSION_MAJOR == 1 && VERSION_MINOR >= 7) #define ZNC17 1 #else #define ZNC17 0 #endif class CClientBufferCacheJob : public CTimer { public: CClientBufferCacheJob(CModule* pModule, unsigned int uInterval, unsigned int uCycles, const CString& sLabel, const CString& sDescription) : CTimer(pModule, uInterval, uCycles, sLabel, sDescription) {} virtual ~CClientBufferCacheJob() {} protected: virtual void RunJob(); }; class CClientBufferMod : public CModule { public: MODCONSTRUCTOR(CClientBufferMod) { AddHelpCommand(); AddCommand("AddClient", static_cast(&CClientBufferMod::OnAddClientCommand), "", "Add a client."); AddCommand("DelClient", static_cast(&CClientBufferMod::OnDelClientCommand), "", "Delete a client."); AddCommand("ListClients", static_cast(&CClientBufferMod::OnListClientsCommand), "", "List known clients."); AddCommand("SetClientTimeLimit", static_cast(&CClientBufferMod::OnSetClientTimeLimit), " [timelimit]", "Change a client's time limit."); AddTimer(new CClientBufferCacheJob(this, 1 /* sec */, 0, "ClientBufferCache", "Periodically save ClientBuffer registry to disk")); } bool OnLoad(const CString& sArgs, CString& sErrorMsg) override { VCString Args; sArgs.Split(" ", Args); for (size_t n=0; n"); return; } if (HasClient(identifier)) { PutModule("Client already exists: " + identifier); return; } AddClient(identifier); PutModule("Client added: " + identifier); } /// Callback for the DelClient module command. void CClientBufferMod::OnDelClientCommand(const CString& line) { const CString identifier = line.Token(1); if (identifier.empty()) { PutModule("Usage: DelClient "); return; } if (!HasClient(identifier)) { PutModule("Unknown client: " + identifier); return; } DelClient(identifier); PutModule("Client removed: " + identifier); } /// Callback for the SetClientTimeLimit module command. void CClientBufferMod::OnSetClientTimeLimit(const CString& line) { const CString identifier = line.Token(1); const int timeLimit = line.Token(2).ToInt(); if (identifier.empty()) { PutModule("Usage: SetClientTimeLimit [timelimit]"); return; } if (!HasClient(identifier)) { PutModule("Client doesn't exist: " + identifier); return; } SetClientTimeLimit(identifier, timeLimit); if (timeLimit) PutModule("Client's " + identifier + " changed time limit: " + CString(timeLimit) ); else PutModule("Client's " + identifier + " cleared time limit."); } /// Callback for the ListClients module command. void CClientBufferMod::OnListClientsCommand(const CString& line) { const CString& current = GetClient()->GetIdentifier(); CTable table; table.AddColumn("Client"); table.AddColumn("TimeLimit"); table.AddColumn("Connected"); for (MCString::iterator it = BeginNV(); it != EndNV(); ++it) { if (it->first.Find("/") == CString::npos) { table.AddRow(); if (it->first == current) table.SetCell("Client", "*" + it->first); else table.SetCell("Client", it->first); table.SetCell("TimeLimit", GetNV(it->first + "/timelimit")); table.SetCell("Connected", CString(!GetNetwork()->FindClients(it->first).empty())); } } if (table.empty()) PutModule("No identified clients"); else PutModule(table); } /// ZNC callback (called when a client successfully logged in to ZNC). /// Implements the "autoadd" option. void CClientBufferMod::OnClientLogin() { const CString& current = GetClient()->GetIdentifier(); if (!HasClient(current) && m_bAutoAdd) { if (current.length() == 0) { PutModule("Not auto-adding a client with an empty identifier."); return; } AddClient(current); } } /// Filter which message kinds cause us to consider the buffer updated. #if ZNC17 static bool WantMessageType(CMessage::Type MessageType) { return MessageType == CMessage::Type::Text || MessageType == CMessage::Type::Notice || MessageType == CMessage::Type::Action || MessageType == CMessage::Type::CTCP; } #else static bool WantMessageCmd(CString cmd) { return cmd == "PRIVMSG" || cmd == "NOTICE"; } #endif /// ZNC callback (called when a client sends any message to ZNC). /// Updates the client "last seen" timestamp. #if ZNC17 CModule::EModRet CClientBufferMod::OnUserRawMessage(CMessage& Message) { CClient* client = Message.GetClient(); if (!client) return CONTINUE; if (WantMessageType(Message.GetType())) UpdateTimestamp(client->GetIdentifier(), GetTarget(Message), Message.GetTime()); return CONTINUE; } #else CModule::EModRet CClientBufferMod::OnUserRaw(CString& line) { CClient* client = GetClient(); if (client) { CNick nick; CString cmd, target; if (ParseMessage(line, nick, cmd, target) && WantMessageCmd(cmd)) UpdateTimestamp(client, target); } return CONTINUE; } #endif /// ZNC callback for messages sent from clients. /// Used in addition to OnUserRawMessage as this one will contain the parsed target. #if ZNC17 CModule::EModRet CClientBufferMod::OnUserTextMessage(CTextMessage& Message) { CClient* client = Message.GetClient(); if (client) UpdateTimestamp(client->GetIdentifier(), GetTarget(Message), Message.GetTime()); return CONTINUE; } #endif /// ZNC callback (called when ZNC sends a raw traffic line to a client). /// Updates the client "last seen" timestamp. #if ZNC17 CModule::EModRet CClientBufferMod::OnSendToClientMessage(CMessage& Message) { // make sure not to update the timestamp for a channel when joining it if (!WantMessageType(Message.GetType())) return CONTINUE; // make sure not to update the timestamp for a channel when attaching it CChan* chan = Message.GetChan(); if (!chan || !chan->IsDetached()) UpdateTimestamp(Message.GetClient()->GetIdentifier(), GetTarget(Message), Message.GetTime()); return CONTINUE; } #else CModule::EModRet CClientBufferMod::OnSendToClient(CString& line, CClient& client) { CIRCNetwork* network = GetNetwork(); if (network) { CNick nick; CString cmd, target; // make sure not to update the timestamp for a channel when attaching it if (ParseMessage(line, nick, cmd, target)) { CChan* chan = network->FindChan(target); if (!chan || !chan->IsDetached()) UpdateTimestamp(&client, target); } } return CONTINUE; } #endif /// ZNC callback (called before a channel buffer is played back to a client). /// Filters out the "Buffer Playback..." message as necessary. CModule::EModRet CClientBufferMod::OnChanBufferStarting(CChan& chan, CClient& client) { if (client.HasServerTime()) return HALTCORE; const CString& identifier = client.GetIdentifier(); if (!HasClient(identifier)) return HALTCORE; // let "Buffer Playback..." message through? const CBuffer& buffer = chan.GetBuffer(); if (!WithinTimeLimit(GetTimestamp(buffer), identifier)) return HALTCORE; if (!buffer.IsEmpty() && HasSeenTimestamp(identifier, chan.GetName(), GetTimestamp(buffer))) return HALTCORE; return CONTINUE; } /// ZNC callback (called after a channel buffer was played back to a client). /// Filters out the "Buffer Complete" message as necessary. CModule::EModRet CClientBufferMod::OnChanBufferEnding(CChan& chan, CClient& client) { if (client.HasServerTime()) return HALTCORE; const CString& identifier = client.GetIdentifier(); if (!HasClient(identifier)) return HALTCORE; // let "Buffer Complete" message through? const CBuffer& buffer = chan.GetBuffer(); if (!WithinTimeLimit(GetTimestamp(buffer), identifier)) return HALTCORE; if (!buffer.IsEmpty() && !UpdateTimestamp(identifier, chan.GetName(), GetTimestamp(buffer))) return HALTCORE; return CONTINUE; } /// ZNC callback (called for each message during a channel's buffer play back). /// Filters out the messages as necessary. #if ZNC17 CModule::EModRet CClientBufferMod::OnChanBufferPlayMessage(CMessage& Message) { CClient* client = Message.GetClient(); if (!client) return CONTINUE; const CString& identifier = client->GetIdentifier(); if (!HasClient(identifier)) return HALTCORE; if (!WithinTimeLimit(Message.GetTime(), identifier)) return HALTCORE; if (HasSeenTimestamp(identifier, GetTarget(Message), Message.GetTime())) return HALTCORE; return CONTINUE; } #else CModule::EModRet CClientBufferMod::OnChanBufferPlayLine2(CChan& chan, CClient& client, CString& line, const timeval& tv) { const CString& identifier = client.GetIdentifier(); if (!HasClient(identifier)) return HALTCORE; if (!WithinTimeLimit(tv, identifier)) return HALTCORE; if (HasSeenTimestamp(identifier, chan.GetName(), tv)) return HALTCORE; return CONTINUE; } #endif /// ZNC callback (called for each message during a query's buffer play back). /// Filters out the messages as necessary. #if ZNC17 CModule::EModRet CClientBufferMod::OnPrivBufferPlayMessage(CMessage& Message) { CClient* client = Message.GetClient(); if (!client) return CONTINUE; const CString& identifier = client->GetIdentifier(); if (!HasClient(identifier)) return HALTCORE; if (!WithinTimeLimit(Message.GetTime(), identifier)) return HALTCORE; if (HasSeenTimestamp(identifier, GetTarget(Message), Message.GetTime())) return HALTCORE; return CONTINUE; } #else CModule::EModRet CClientBufferMod::OnPrivBufferPlayLine2(CClient& client, CString& line, const timeval& tv) { const CString& identifier = client.GetIdentifier(); if (!HasClient(identifier)) return HALTCORE; if (!WithinTimeLimit(tv, identifier)) return HALTCORE; CNick nick; CString cmd, target; if (ParseMessage(line, nick, cmd, target) && !UpdateTimestamp(identifier, target, tv)) return HALTCORE; return CONTINUE; } #endif /// Add a client identifier. /// Returns true upon success. bool CClientBufferMod::AddClient(const CString& identifier) { m_bDirty = true; return SetNV(identifier, "", false); } /// Remove a client identifier. /// Returns true upon success. bool CClientBufferMod::DelClient(const CString& identifier) { SCString keys; for (MCString::iterator it = BeginNV(); it != EndNV(); ++it) { const CString client = it->first.Token(0, false, "/"); if (client.Equals(identifier)) keys.insert(it->first); } bool success = true; for (const CString& key : keys) success &= DelNV(key, false); m_bDirty = true; return success; } /// Check whether a client identifier is known. bool CClientBufferMod::HasClient(const CString& identifier) { return !identifier.empty() && FindNV(identifier) != EndNV(); } /// Set a client's timelimit. /// Returns true upon success. bool CClientBufferMod::SetClientTimeLimit(const CString& identifier, const int timeLimit) { m_bDirty = true; if (timeLimit) return SetNV(identifier + "/timelimit", CString(timeLimit), false); else return DelNV(identifier + "/timelimit", false); } #if ZNC17 CString CClientBufferMod::GetTarget(const CMessage& msg) { if (msg.GetChan()) return msg.GetChan()->GetName(); else { CString Nick = msg.GetNick().GetNick(); CIRCNetwork* Network = msg.GetNetwork(); // Detect self-messages if (Network && Nick == Network->GetNick() && msg.GetParams().size() >= 1) return msg.GetParam(0); return Nick; } } #else /// Split an IRC message line into parts. /// Populates nick, cmd and target. /// Returns true upon success. bool CClientBufferMod::ParseMessage(const CString& line, CNick& nick, CString& cmd, CString& target) const { // discard message tags CString msg = line; if (msg.StartsWith("@")) msg = msg.Token(1, true); CString rest; if (msg.StartsWith(":")) { nick = CNick(msg.Token(0).TrimPrefix_n()); cmd = msg.Token(1); rest = msg.Token(2, true); } else { cmd = msg.Token(0); rest = msg.Token(1, true); } if (cmd.length() == 3 && isdigit(cmd[0]) && isdigit(cmd[1]) && isdigit(cmd[2])) { // must block the following numeric replies that are automatically sent on attach: // RPL_NAMREPLY, RPL_ENDOFNAMES, RPL_TOPIC, RPL_TOPICWHOTIME... unsigned int num = cmd.ToUInt(); if (num == 353) // RPL_NAMREPLY target = rest.Token(2); else target = rest.Token(1); } else if (cmd.Equals("PRIVMSG") || cmd.Equals("NOTICE") || cmd.Equals("JOIN") || cmd.Equals("PART") || cmd.Equals("MODE") || cmd.Equals("KICK") || cmd.Equals("TOPIC")) { target = rest.Token(0).TrimPrefix_n(":"); } return !target.empty() && !cmd.empty(); } #endif /// Get the "last seen" timestamp for a given client identifier and target. timeval CClientBufferMod::GetTimestamp(const CString& identifier, const CString& target) { CString timestamp = GetNV(identifier + "/" + target); long long sec = 0; long usec = 0; std::sscanf(timestamp.c_str(), "%lld.%06ld", &sec, &usec); timeval tv; tv.tv_sec = (time_t)sec; tv.tv_usec = (suseconds_t)usec; return tv; } /// Get the timestamp of the last message in a given ZNC playback buffer. timeval CClientBufferMod::GetTimestamp(const CBuffer& buffer) const { return buffer.GetBufLine(buffer.Size() - 1).GetTime(); } /// Set the "last seen" timestamp for a given client identifier and target. bool CClientBufferMod::SetTimestamp(const CString& identifier, const CString& target, const timeval& tv) { char timestamp[32]; std::snprintf(timestamp, 32, "%lld.%06ld", (long long)tv.tv_sec, (long)tv.tv_usec); m_bDirty = true; return SetNV(identifier + "/" + target, timestamp, false); } /// Returns true if the given timestamp is not greater than the "last /// seen" timestamp for the given client identifier and target. bool CClientBufferMod::HasSeenTimestamp(const CString& identifier, const CString& target, const timeval& tv) { const timeval seen = GetTimestamp(identifier, target); return !timercmp(&seen, &tv, <); } /// Checks whether the given client should receive a message with the /// given timestamp and target. /// If the timestamp is greater than the client's "last seen" /// timestamp, updates the client's "last seen" timestamp accordingly /// and returns true. Otherwise, returns false. bool CClientBufferMod::UpdateTimestamp(const CString& identifier, const CString& target, const timeval& tv) { if (!HasSeenTimestamp(identifier, target, tv)) return SetTimestamp(identifier, target, tv); return false; } #if !ZNC17 /// Update the "last seen" timestamp of the given client and target to /// the current time. void CClientBufferMod::UpdateTimestamp(const CClient* client, const CString& target) { if (client && !client->IsPlaybackActive()) { const CString& identifier = client->GetIdentifier(); if (HasClient(identifier)) { timeval tv; gettimeofday(&tv, NULL); UpdateTimestamp(identifier, target, tv); } } } #endif bool CClientBufferMod::WithinTimeLimit(const timeval& tv, const CString& identifier) { int timeLimit = GetNV(identifier + "/timelimit").ToInt(); if (!timeLimit && !m_iTimeLimit) return true; timeval now; gettimeofday(&now, NULL); return now.tv_sec - tv.tv_sec < timeLimit ? timeLimit : m_iTimeLimit; } template<> void TModInfo(CModInfo& info) { info.SetWikiPage("Clientbuffer"); info.SetHasArgs(true); } void CClientBufferCacheJob::RunJob() { CClientBufferMod* mod = (CClientBufferMod*)GetModule(); mod->FlushRegistry(); } void CClientBufferMod::FlushRegistry() { if (m_bDirty) { SaveRegistry(); m_bDirty = false; } } NETWORKMODULEDEFS(CClientBufferMod, "Client specific buffer playback")