/[sudobot]/branches/7.x/src/automod/MessageRuleService.ts
ViewVC logotype

Contents of /branches/7.x/src/automod/MessageRuleService.ts

Parent Directory Parent Directory | Revision Log Revision Log


Revision 577 - (show annotations)
Mon Jul 29 18:52:37 2024 UTC (8 months ago) by rakinar2
File MIME type: application/typescript
File size: 29809 byte(s)
chore: add old version archive branches (2.x to 9.x-dev)
1 /**
2 * This file is part of SudoBot.
3 *
4 * Copyright (C) 2021-2023 OSN Developers.
5 *
6 * SudoBot is free software; you can redistribute it and/or modify it
7 * under the terms of the GNU Affero General Public License as published by
8 * the Free Software Foundation, either version 3 of the License, or
9 * (at your option) any later version.
10 *
11 * SudoBot is distributed in the hope that it will be useful, but
12 * WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU Affero General Public License for more details.
15 *
16 * You should have received a copy of the GNU Affero General Public License
17 * along with SudoBot. If not, see <https://www.gnu.org/licenses/>.
18 */
19
20 // TODO: Add an allow/disallow mode to all roles
21
22 import {
23 Attachment,
24 EmbedAssetData,
25 Message,
26 PermissionFlagsBits,
27 Snowflake,
28 TextChannel,
29 escapeCodeBlock,
30 escapeInlineCode,
31 escapeMarkdown
32 } from "discord.js";
33 import Service from "../core/Service";
34 import { CreateLogEmbedOptions } from "../services/LoggerService";
35 import { HasEventListeners } from "../types/HasEventListeners";
36 import { MessageRuleType } from "../types/MessageRuleSchema";
37 import { log, logError, logWarn } from "../utils/logger";
38 import { escapeRegex, getEmoji, request } from "../utils/utils";
39
40 export const name = "messageRuleService";
41
42 type RuleHandlerMethod = Extract<keyof MessageRuleService, `rule${string}`>;
43
44 type RuleInfo =
45 | RuleHandlerMethod
46 | {
47 method: RuleHandlerMethod;
48 autoHandleModes?: boolean;
49 };
50
51 const handlers: Record<MessageRuleType["type"], RuleInfo> = {
52 domain: {
53 method: "ruleDomain",
54 autoHandleModes: false
55 },
56 blocked_file_extension: "ruleBlockedFileExtension",
57 blocked_mime_type: "ruleBlockedMimeType",
58 anti_invite: "ruleAntiInvite",
59 regex_filter: "ruleRegexFilter",
60 block_repeated_text: "ruleRepeatedText",
61 block_mass_mention: "ruleBlockMassMention",
62 regex_must_match: "ruleRegexMustMatch",
63 image: "ruleImage",
64 embed: "ruleEmbed",
65 EXPERIMENTAL_url_crawl: "ruleURLCrawl"
66 };
67
68 type MessageRuleAction = MessageRuleType["actions"][number];
69
70 // TODO: Allow specific words/tokens even after having rules that disallow it
71 // TODO: Introduce rule bypassers ^^
72
73 export default class MessageRuleService extends Service implements HasEventListeners {
74 private config(guildId: Snowflake) {
75 return this.client.configManager.config[guildId]?.message_rules;
76 }
77
78 async onMessageCreate(message: Message<boolean>) {
79 if (message.author.bot) {
80 return false;
81 }
82
83 const config = this.config(message.guildId!);
84
85 if (
86 !config?.enabled ||
87 config?.global_disabled_channels?.includes(message.channelId!) ||
88 (await this.client.permissionManager.isImmuneToAutoMod(message.member!, PermissionFlagsBits.ManageGuild))
89 ) {
90 return false;
91 }
92
93 return this.processMessageRules(message, config.rules);
94 }
95
96 private async processMessageRules(message: Message, rules: Array<MessageRuleType>) {
97 for (const rule of rules) {
98 if (rule.actions.length === 0) {
99 log("No action found in this rule! Considering it as disabled.");
100 continue;
101 }
102
103 if (rule.actions.length === 1 && rule.actions.includes("delete") && !message.deletable) {
104 log("Missing permissions to delete messages, but the rule actions include `delete`. Skipping.");
105 continue;
106 }
107
108 if (rule.actions.includes("mute") && rule.actions.includes("warn")) {
109 logWarn("You cannot include mute and warn together as message rule actions! Skipping.");
110 continue;
111 }
112
113 if (rule.disabled_channels.includes(message.channelId!)) {
114 logWarn("This rule is disabled in this channel.");
115 continue;
116 }
117
118 if (rule.immune_users.includes(message.author.id)) {
119 logWarn("This user is immune to this rule.");
120 continue;
121 }
122
123 if (message.member?.roles.cache.hasAny(...rule.immune_roles)) {
124 logWarn("This user is immune to this rule, due to having some whitelisted roles.");
125 continue;
126 }
127
128 const handlerFunctionInfo = handlers[rule.type];
129 const handlerFunctionName: RuleHandlerMethod | undefined =
130 typeof handlerFunctionInfo === "string" ? handlerFunctionInfo : handlerFunctionInfo?.method;
131 const handlerFunctionMetaInfo = (typeof handlerFunctionInfo === "string" ? null : handlerFunctionInfo) ?? {
132 method: handlerFunctionName,
133 autoHandleModes: true
134 };
135
136 if (!handlerFunctionInfo || !handlerFunctionName?.startsWith("rule")) {
137 continue;
138 }
139
140 const handler = this[handlerFunctionName] as (
141 ...args: any[]
142 ) => Promise<boolean | null | undefined | CreateLogEmbedOptions>;
143
144 if (typeof handler !== "function") {
145 continue;
146 }
147
148 try {
149 const result = await handler.call(this, message, rule);
150 const inverse = rule.mode === "inverse";
151 const { autoHandleModes } = handlerFunctionMetaInfo;
152
153 if ((result && !inverse) || (inverse && ((autoHandleModes && !result) || (!autoHandleModes && result)))) {
154 try {
155 for (const action of rule.actions) {
156 log("Taking action: ", action);
157 await this.takeAction(message, rule, action);
158 }
159
160 const embedOptions =
161 result && typeof result === "object" && !inverse
162 ? result
163 : inverse
164 ? {
165 options: {
166 description: `${getEmoji(this.client, "info")} This rule was __inversed__.`,
167 ...(result && typeof result === "object" && "options" in result ? result.options : {})
168 },
169 ...(result && typeof result === "object" ? result : {})
170 }
171 : undefined;
172
173 log(embedOptions);
174
175 await this.client.logger
176 .logMessageRuleAction({
177 message,
178 actions: rule.actions,
179 rule: rule.type,
180 embedOptions
181 })
182 .catch(logError);
183 } catch (e) {
184 logError(e);
185 }
186
187 return true;
188 }
189 } catch (e) {
190 logError(e);
191 continue;
192 }
193 }
194
195 return false;
196 }
197
198 private getReason(rule: MessageRuleType, key: Extract<keyof MessageRuleType, `${string}_reason`>) {
199 return rule[key] ?? rule.common_reason ?? "Your message violated the server rules. Please be careful next time.";
200 }
201
202 private async takeAction(message: Message, rule: MessageRuleType, action: MessageRuleAction) {
203 switch (action) {
204 case "delete":
205 if (message.deletable) {
206 await message.delete().catch(logError);
207 }
208
209 break;
210
211 case "verbal_warn":
212 {
213 const content = this.getReason(rule, "verbal_warning_reason");
214
215 await message.reply({
216 content
217 });
218 }
219
220 break;
221
222 case "warn":
223 {
224 const reason = this.getReason(rule, "warning_reason");
225
226 await this.client.infractionManager.createMemberWarn(message.member!, {
227 guild: message.guild!,
228 moderator: this.client.user!,
229 notifyUser: true,
230 reason,
231 sendLog: true
232 });
233 }
234
235 break;
236
237 case "mute":
238 {
239 const reason = this.getReason(rule, "mute_reason");
240
241 await this.client.infractionManager.createMemberMute(message.member!, {
242 guild: message.guild!,
243 moderator: this.client.user!,
244 notifyUser: true,
245 reason,
246 sendLog: true,
247 autoRemoveQueue: true,
248 duration: rule.mute_duration === -1 ? 60_000 : rule.mute_duration
249 });
250 }
251
252 break;
253
254 case "clear":
255 {
256 await this.client.infractionManager.bulkDeleteMessages({
257 guild: message.guild!,
258 moderator: this.client.user!,
259 count: 50,
260 sendLog: true,
261 messageChannel: message.channel! as TextChannel,
262 user: message.member!.user,
263 reason: "Message rule triggered"
264 });
265 }
266
267 break;
268 }
269 }
270
271 private scanForBlockedWordsAndTokens(tokens: string[] = [], words: string[] = [], ...strings: (string | null | undefined)[]) {
272 for (const string of strings) {
273 if (!string) {
274 continue;
275 }
276
277 for (const token of tokens) {
278 if (string.includes(token)) {
279 return { includes: true, token };
280 }
281 }
282
283 const splitted = string.split(/\s+/);
284
285 for (const word of words) {
286 if (splitted.includes(word)) {
287 return { includes: true, word };
288 }
289 }
290 }
291
292 return { includes: false };
293 }
294
295 async ruleEmbed(message: Message, rule: Extract<MessageRuleType, { type: "embed" }>) {
296 if (message.embeds.length === 0) {
297 return null;
298 }
299
300 const config = this.client.configManager.config[message.guildId!]?.message_filter;
301 const { mode, tokens, words, inherit_from_word_filter } = rule;
302
303 if (config?.enabled && inherit_from_word_filter) {
304 words.push(...config.data.blocked_words);
305 tokens.push(...config.data.blocked_tokens);
306 }
307
308 for (const embed of message.embeds) {
309 const fieldsStrings = embed.fields.reduce((acc, value) => {
310 acc.push(value.name, value.value);
311 return acc;
312 }, [] as string[]);
313 const { includes, word, token } = this.scanForBlockedWordsAndTokens(
314 tokens,
315 words,
316 embed.author?.name,
317 embed.description,
318 embed.title,
319 embed.footer?.text,
320 ...fieldsStrings
321 );
322
323 if (includes && mode === "normal") {
324 return {
325 title: `Blocked ${token ? "token" : word ? "word" : "word/token"}(s) detected in embed`,
326 fields: [
327 {
328 name: `${token ? "Token" : word ? "Word" : "Word/Token"}`,
329 value: `||${escapeMarkdown(token ?? word ?? "[???]")}||`
330 },
331 {
332 name: "Method",
333 value: "Embed Scan"
334 }
335 ]
336 } as CreateLogEmbedOptions;
337 } else if (!includes && mode === "inverse") {
338 return {
339 title: `Blocked ${token ? "token" : word ? "word" : "word/token"}(s) was not found in embed`,
340 fields: [
341 {
342 name: `${token ? "Token" : word ? "Word" : "Word/Token"}`,
343 value: `||${escapeMarkdown(token ?? word ?? "[???]")}||`
344 },
345 {
346 name: "Method",
347 value: "Embed Scan"
348 }
349 ]
350 } as CreateLogEmbedOptions;
351 }
352 }
353
354 return null;
355 }
356
357 async ruleImage(message: Message, rule: Extract<MessageRuleType, { type: "image" }>) {
358 if (message.attachments.size === 0 && (!rule.scan_embeds || message.embeds.length === 0)) {
359 return null;
360 }
361
362 const config = this.client.configManager.config[message.guildId!]?.message_filter;
363 const { mode, tokens, words, inherit_from_word_filter, scan_embeds } = rule;
364
365 if (config?.enabled && inherit_from_word_filter) {
366 words.push(...config.data.blocked_words);
367 tokens.push(...config.data.blocked_tokens);
368 }
369
370 const attachments: Array<EmbedAssetData | Attachment> = [...message.attachments.values()];
371
372 if (scan_embeds) {
373 for (const embed of message.embeds) {
374 if (embed.image) {
375 attachments.push(embed.image);
376 }
377
378 if (embed.thumbnail) {
379 attachments.push(embed.thumbnail);
380 }
381
382 if (embed.author?.proxyIconURL ?? embed.author?.iconURL) {
383 attachments.push({
384 url: embed.author?.proxyIconURL ?? embed.author?.iconURL!
385 });
386 }
387 }
388 }
389
390 for (const attachment of attachments) {
391 if (attachment instanceof Attachment && !attachment.contentType?.startsWith("image/")) {
392 log(`Not scanning attachment ${attachment.id} as it's not an image`);
393 continue;
394 }
395
396 const {
397 data: { text: actualText, words: textWords }
398 } = await this.client.imageRecognitionService.recognize(attachment.proxyURL ?? attachment.url);
399 const text = actualText.toLowerCase();
400
401 for (const token of tokens) {
402 const includes = text.includes(token.toLowerCase());
403
404 if (includes && mode === "normal") {
405 return {
406 title: "Blocked token(s) detected in image",
407 fields: [
408 {
409 name: "Token",
410 value: `||${escapeMarkdown(token)}||`
411 },
412 {
413 name: "Method",
414 value: "Image Scan"
415 }
416 ]
417 } satisfies CreateLogEmbedOptions;
418 } else if (!includes && mode === "inverse") {
419 return {
420 title: "Required token(s) were not found in image",
421 fields: [
422 {
423 name: "Token",
424 value: `||${escapeMarkdown(token)}||`
425 },
426 {
427 name: "Method",
428 value: "Image Scan"
429 }
430 ]
431 } satisfies CreateLogEmbedOptions;
432 }
433 }
434
435 for (const textWord of textWords) {
436 const includes = words.includes(textWord.text.toLowerCase());
437
438 if (includes && mode === "normal") {
439 return {
440 title: "Blocked word(s) detected in image",
441 fields: [
442 {
443 name: "Word",
444 value: `||${escapeMarkdown(textWord.text)}||`
445 },
446 {
447 name: "Method",
448 value: "Image Scan"
449 }
450 ]
451 } satisfies CreateLogEmbedOptions;
452 } else if (!includes && mode === "inverse") {
453 return {
454 title: "Required word(s) were not found in image",
455 fields: [
456 {
457 name: "Word",
458 value: `||${escapeMarkdown(textWord.text)}||`
459 },
460 {
461 name: "Method",
462 value: "Image Scan"
463 }
464 ]
465 } satisfies CreateLogEmbedOptions;
466 }
467 }
468 }
469
470 log("Image scan passed");
471 return null;
472 }
473
474 /** This rule is experimental. It needs caching support. */
475 async ruleURLCrawl(message: Message, rule: Extract<MessageRuleType, { type: "EXPERIMENTAL_url_crawl" }>) {
476 if (message.content.trim() === "") {
477 return null;
478 }
479
480 const { excluded_domains_regex, excluded_link_regex, excluded_links, words, tokens, inherit_from_word_filter, mode } =
481 rule;
482 const config = this.client.configManager.config[message.guildId!]?.message_filter;
483
484 const matches = message.content.matchAll(/https?:\/\/([A-Za-z0-9-\.]*[A-Za-z0-9-])[\S]*/gim);
485
486 for (const match of matches) {
487 const url = match[0].toLowerCase();
488 const domain = match[1].toLowerCase();
489
490 if (excluded_links.includes(url)) {
491 return null;
492 }
493
494 for (const regex of excluded_domains_regex) {
495 if (new RegExp(regex, "gim").test(domain)) {
496 return null;
497 }
498 }
499
500 for (const regex of excluded_link_regex) {
501 if (new RegExp(regex, "gim").test(url)) {
502 return null;
503 }
504 }
505 }
506
507 if (config?.enabled && inherit_from_word_filter) {
508 words.push(...config.data.blocked_words);
509 tokens.push(...config.data.blocked_tokens);
510 }
511
512 for (const match of matches) {
513 const url = match[0].toLowerCase();
514
515 const [response, error] = await request({
516 url,
517 method: "GET",
518 transformResponse: r => r
519 });
520
521 if (error) {
522 logError(error);
523 continue;
524 }
525
526 if (typeof response?.data !== "string") {
527 logWarn("The response returned by the server during URL crawl is invalid");
528 continue;
529 }
530
531 const lowerCasedData = response.data.toLowerCase();
532
533 for (const token of tokens) {
534 if (lowerCasedData.includes(token)) {
535 return {
536 title: "Website contains blocked token(s)",
537 fields: [
538 {
539 name: "Token",
540 value: `||${token}||`
541 },
542 {
543 name: "Method",
544 value: "URL Crawling"
545 }
546 ]
547 } satisfies CreateLogEmbedOptions;
548 }
549 }
550
551 const textWords = lowerCasedData.split(/\s+/);
552
553 for (const word of words) {
554 if (textWords.includes(word)) {
555 return {
556 title: "Website contains blocked word(s)",
557 fields: [
558 {
559 name: "Word",
560 value: `||${word}||`
561 },
562 {
563 name: "Method",
564 value: "URL Crawling"
565 }
566 ]
567 } satisfies CreateLogEmbedOptions;
568 }
569 }
570 }
571
572 return null;
573 }
574
575 async ruleDomain(message: Message, rule: Extract<MessageRuleType, { type: "domain" }>) {
576 if (message.content.trim() === "") {
577 return null;
578 }
579
580 const { domains, scan_links_only, mode } = rule;
581
582 const prefix = scan_links_only ? `(https?://)` : `(https?://)?`;
583 let specificRegex = `${prefix}(`;
584 const genericRegex = `${prefix}([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})`;
585
586 let index = 0;
587 for (const domain of domains) {
588 const escapedDomain = escapeRegex(domain).replace(/\\\*/g, "[A-Za-z0-9-]+");
589 specificRegex += `${escapedDomain}`;
590
591 if (index < domains.length - 1) {
592 specificRegex += "|";
593 }
594
595 index++;
596 }
597
598 specificRegex += ")\\S*";
599 log(specificRegex);
600
601 const specificMatches = [...(new RegExp(specificRegex, "i").exec(message.content) ?? [])];
602 log(specificMatches);
603
604 const genericMatches = [...(new RegExp(genericRegex, "i").exec(message.content) ?? [])];
605 log(genericMatches);
606
607 if (specificMatches.length > 0) {
608 const cleanedDomain = (specificMatches[2] ?? specificMatches[1] ?? specificMatches[0]).replace(/https?:\/\//, "");
609 if (mode === "normal") {
610 return {
611 title: "Blocked domain(s) detected",
612 fields: [
613 {
614 name: "Domain",
615 value: `\`${escapeMarkdown(cleanedDomain)}\``
616 }
617 ]
618 } satisfies CreateLogEmbedOptions;
619 } else if (mode === "inverse") {
620 return false;
621 }
622 } else if (genericMatches.length > 0 && mode === "inverse" && !scan_links_only) {
623 const cleanedDomain = (genericMatches[2] ?? genericMatches[1] ?? genericMatches[0]).replace(/https?:\/\//, "");
624 return {
625 title: "Blocked domain(s) detected",
626 fields: [
627 {
628 name: "Domain",
629 value: `\`${escapeMarkdown(cleanedDomain)}\``
630 }
631 ]
632 } satisfies CreateLogEmbedOptions;
633 }
634
635 return false;
636 }
637
638 async ruleBlockedFileExtension(message: Message, rule: Extract<MessageRuleType, { type: "blocked_file_extension" }>) {
639 for (const attachment of message.attachments.values()) {
640 for (const extension of rule.data) {
641 if (attachment.proxyURL.endsWith(`.${extension}`)) {
642 return {
643 title: "File(s) with blocked extensions found",
644 fields: [
645 {
646 name: "File",
647 value: `[${attachment.name}](${attachment.url}): \`.${escapeMarkdown(extension)}\``
648 }
649 ]
650 };
651 }
652 }
653 }
654
655 return null;
656 }
657
658 async ruleBlockedMimeType(message: Message, rule: Extract<MessageRuleType, { type: "blocked_mime_type" }>) {
659 for (const attachment of message.attachments.values()) {
660 if (rule.data.includes(attachment.contentType ?? "unknown")) {
661 return {
662 title: "File(s) with blocked MIME-type found",
663 fields: [
664 {
665 name: "File",
666 value: `[${attachment.name}](${attachment.url}): \`${attachment.contentType}\``
667 }
668 ]
669 };
670 }
671 }
672
673 return null;
674 }
675
676 async ruleAntiInvite(message: Message, rule: Extract<MessageRuleType, { type: "anti_invite" }>) {
677 if (message.content.trim() === "") {
678 return null;
679 }
680
681 const allowedInviteCodes = rule.allowed_invite_codes;
682 const regex = /(https?:\/\/)?discord.(gg|com\/invite)\/([A-Za-z0-9_]+)/gi;
683 const matches = message.content.matchAll(regex);
684
685 for (const match of matches) {
686 if (match[3] && !allowedInviteCodes.includes(match[3])) {
687 if (rule.allow_internal_invites && this.client.inviteTracker.invites.has(`${message.guildId!}_${match[3]}`)) {
688 continue;
689 }
690
691 return {
692 title: "Posted Invite(s)",
693 fields: [
694 {
695 name: "Invite URL",
696 value: `\`https://discord.gg/${match[3]}\``
697 }
698 ]
699 };
700 }
701 }
702
703 return null;
704 }
705
706 async ruleRegexMustMatch(message: Message, rule: Extract<MessageRuleType, { type: "regex_must_match" }>) {
707 if (message.content.trim() === "") {
708 return null;
709 }
710
711 const { patterns } = rule;
712
713 for (const pattern of patterns) {
714 const regex = new RegExp(
715 typeof pattern === "string" ? pattern : pattern[0],
716 typeof pattern === "string" ? "gi" : pattern[1]
717 );
718
719 if (regex.test(message.content)) {
720 return null;
721 }
722 }
723
724 return {
725 title: "Message did not match with the specified regex patterns"
726 };
727 }
728
729 async ruleRegexFilter(message: Message, rule: Extract<MessageRuleType, { type: "regex_filter" }>) {
730 if (message.content.trim() === "") {
731 return null;
732 }
733
734 const { patterns } = rule;
735
736 for (const pattern of patterns) {
737 const regex = new RegExp(
738 typeof pattern === "string" ? pattern : pattern[0],
739 typeof pattern === "string" ? "gi" : pattern[1]
740 );
741
742 if (regex.test(message.content)) {
743 return {
744 title: "Message matched with a blocked regex pattern",
745 fields: [
746 {
747 name: "Pattern Info",
748 value: `Pattern: \`${escapeInlineCode(
749 escapeCodeBlock(typeof pattern === "string" ? pattern : pattern[0])
750 )}\`\nFlags: \`${escapeInlineCode(
751 escapeCodeBlock(typeof pattern === "string" ? "gi" : pattern[1])
752 )}\``
753 }
754 ]
755 };
756 }
757 }
758
759 return null;
760 }
761
762 async ruleRepeatedText(message: Message, rule: Extract<MessageRuleType, { type: "block_repeated_text" }>) {
763 if (message.content.trim() === "") {
764 return null;
765 }
766
767 if (new RegExp("(.+)\\1{" + rule.max_repeated_chars + ",}", "gm").test(message.content)) {
768 return {
769 title: "Repeated text detected",
770 fields: [
771 {
772 name: "Description",
773 value: `Too many repetitive characters were found`
774 }
775 ]
776 };
777 } else if (new RegExp("^(.+)(?: +\\1){" + rule.max_repeated_words + "}", "gm").test(message.content)) {
778 return {
779 title: "Repeated text detected",
780 fields: [
781 {
782 name: "Description",
783 value: `Too many repetitive words were found`
784 }
785 ]
786 };
787 }
788
789 return null;
790 }
791
792 async ruleBlockMassMention(message: Message, rule: Extract<MessageRuleType, { type: "block_mass_mention" }>) {
793 if (message.content.trim() === "") {
794 return null;
795 }
796
797 let data = [...message.content.matchAll(new RegExp(`\<\@[0-9]+\>`, "gm"))];
798
799 console.log("users", data);
800
801 if (data.length >= rule.max_mentions || (rule.max_user_mentions > 0 && data.length >= rule.max_user_mentions)) {
802 return {
803 title: "Mass mentions detected",
804 fields: [
805 {
806 name: "Description",
807 value: `Too many users were mentioned`
808 }
809 ]
810 };
811 }
812
813 data = [...message.content.matchAll(new RegExp(`\<\@\&[0-9]+\>`, "gm"))];
814
815 if (data.length >= rule.max_mentions || (rule.max_role_mentions > 0 && data.length >= rule.max_role_mentions)) {
816 return {
817 title: "Repeated text detected",
818 fields: [
819 {
820 name: "Description",
821 value: `Too many roles were mentioned`
822 }
823 ]
824 };
825 }
826
827 return null;
828 }
829 }

[email protected]
ViewVC Help
Powered by ViewVC 1.1.26