diff --git a/.gitignore b/.gitignore index 47e5b2c..950c012 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ dist vendor/ bin/ +podman/** +!podman/env.example +.env diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md old mode 100755 new mode 100644 index 1933e3b..961a752 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,7 +37,7 @@ This document is a primer for developing in this repository. ``` 4. **Mattermost Server** (9.3.0+) - - Either local installation or Docker container + - Either local installation or Podman container - Admin access for plugin management 5. **Git** @@ -58,7 +58,7 @@ This document is a primer for developing in this repository. ``` This installs: - - `golangci-lint v2.5.0` - Go code linter + - `golangci-lint v2.6.0` - Go code linter - `gotestsum v1.13.0` - Enhanced test runner 3. **Configure plugin deployment (optional - for local testing):** @@ -111,60 +111,60 @@ The project uses GNU Make for build automation. Here are all available commands: ### Primary Build Commands -| Command | Description | Usage | -|---------|-------------|-------| -| `make all` | Complete build pipeline: check-style → test → dist | Use before committing | -| `make dist` | Build production plugin bundle | Creates `dist/mattermost-community-toolkit-*.tar.gz` | -| `make server` | Build server binaries only | Builds for linux-amd64 and linux-arm64 | -| `make webapp` | Build webapp (currently disabled) | N/A - webapp is WIP | -| `make bundle` | Create distribution tarball | Packages built artifacts | -| `make clean` | Remove all build artifacts | Clean slate rebuild | +| Command | Description | Usage | +| ------------- | -------------------------------------------------- | ---------------------------------------------------- | +| `make all` | Complete build pipeline: check-style → test → dist | Use before committing | +| `make dist` | Build production plugin bundle | Creates `dist/mattermost-community-toolkit-*.tar.gz` | +| `make server` | Build server binaries only | Builds for linux-amd64 and linux-arm64 | +| `make webapp` | Build webapp (currently disabled) | N/A - webapp is WIP | +| `make bundle` | Create distribution tarball | Packages built artifacts | +| `make clean` | Remove all build artifacts | Clean slate rebuild | ### Development Commands -| Command | Description | Usage | -|---------|-------------|-------| -| `make apply` | Propagate manifest changes | Run after editing `plugin.json` | -| `make deploy` | Build and install to server | Requires configured `pluginctl` | -| `make watch` | Auto-rebuild on file changes | For webapp development | -| `make deploy-from-watch` | Deploy watched changes | Use with `make watch` | +| Command | Description | Usage | +| ------------------------ | ---------------------------- | ------------------------------- | +| `make apply` | Propagate manifest changes | Run after editing `plugin.json` | +| `make deploy` | Build and install to server | Requires configured `pluginctl` | +| `make watch` | Auto-rebuild on file changes | For webapp development | +| `make deploy-from-watch` | Deploy watched changes | Use with `make watch` | ### Testing Commands -| Command | Description | Usage | -|---------|-------------|-------| -| `make test` | Run all tests with race detection | Standard test run | -| `make test-ci` | CI-optimized test with JUnit output | For CI pipelines | -| `make coverage` | Generate test coverage report | Opens HTML report in browser | -| `make check-style` | Run linters (Go + JS) | Must pass before commit | +| Command | Description | Usage | +| ------------------ | ----------------------------------- | ---------------------------- | +| `make test` | Run all tests with race detection | Standard test run | +| `make test-ci` | CI-optimized test with JUnit output | For CI pipelines | +| `make coverage` | Generate test coverage report | Opens HTML report in browser | +| `make check-style` | Run linters (Go + JS) | Must pass before commit | ### Plugin Management Commands -| Command | Description | Usage | -|---------|-------------|-------| -| `make enable` | Enable the plugin | After deployment | -| `make disable` | Disable the plugin | For testing | -| `make reset` | Restart plugin (disable + enable) | Quick restart | -| `make kill` | Force kill plugin process | Emergency stop | -| `make logs` | View plugin logs | Debugging | -| `make logs-watch` | Tail plugin logs | Real-time monitoring | +| Command | Description | Usage | +| ----------------- | --------------------------------- | -------------------- | +| `make enable` | Enable the plugin | After deployment | +| `make disable` | Disable the plugin | For testing | +| `make reset` | Restart plugin (disable + enable) | Quick restart | +| `make kill` | Force kill plugin process | Emergency stop | +| `make logs` | View plugin logs | Debugging | +| `make logs-watch` | Tail plugin logs | Real-time monitoring | ### Debugging Commands -| Command | Description | Usage | -|---------|-------------|-------| -| `make attach` | Attach dlv debugger | Interactive debugging | -| `make attach-headless` | Headless dlv on port 2346 | Remote debugging | -| `make detach` | Detach debugger | Stop debugging | -| `make setup-attach` | Find plugin PID | Internal use | +| Command | Description | Usage | +| ---------------------- | ------------------------- | --------------------- | +| `make attach` | Attach dlv debugger | Interactive debugging | +| `make attach-headless` | Headless dlv on port 2346 | Remote debugging | +| `make detach` | Detach debugger | Stop debugging | +| `make setup-attach` | Find plugin PID | Internal use | ### Utility Commands -| Command | Description | Usage | -|---------|-------------|-------| -| `make install-go-tools` | Install required Go tools | First-time setup | -| `make i18n-extract` | Extract translatable strings | For localization | -| `make help` | Show all available commands | Quick reference | +| Command | Description | Usage | +| ----------------------- | ---------------------------- | ---------------- | +| `make install-go-tools` | Install required Go tools | First-time setup | +| `make i18n-extract` | Extract translatable strings | For localization | +| `make help` | Show all available commands | Quick reference | ### Environment Variables @@ -268,7 +268,7 @@ goimports -w -local github.com/rocky-linux/mattermost-plugin-community-toolkit s ### Linting Rules -We use `golangci-lint v2.5.0+` with linting rules available in the file `.golangci.yml` +We use `golangci-lint v2.6.0+` with linting rules configured in `.golangci.yml` ## Debugging @@ -308,7 +308,7 @@ We use `golangci-lint v2.5.0+` with linting rules available in the file `.golang ## Additional Resources -- [Plugin Architecture Overview](.local/ARCHITECTURE_OVERVIEW.md) - [Mattermost Plugin Developer Docs](https://developers.mattermost.com/integrate/plugins/) - [Go Documentation](https://go.dev/doc/) -- [Original Repository](https://github.com/mattermost/mattermost-plugin-profanity-filter) +- [Podman Development Environment](docs/PODMAN_DEVELOPMENT.md) +- [New User Moderation Features](docs/NEW_USER_MODERATION.md) diff --git a/Makefile b/Makefile index 50a8a89..68bc9cd 100644 --- a/Makefile +++ b/Makefile @@ -52,7 +52,7 @@ apply: ## Install go tools install-go-tools: @echo Installing go tools - $(GO) install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.5.0 + $(GO) install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.6.0 $(GO) install gotest.tools/gotestsum@v1.13.0 ## Runs eslint and golangci-lint @@ -94,6 +94,10 @@ else endif endif +## Alias for server target - builds the server component. +.PHONY: build +build: server + ## Ensures NPM dependencies are installed without having to run this all the time. webapp/node_modules: $(wildcard webapp/package.json) ifneq ($(HAS_WEBAPP),) @@ -279,6 +283,24 @@ ifneq ($(HAS_WEBAPP),) endif rm -fr build/bin/ +## Formats Go and Markdown code. +.PHONY: format +format: + @echo Formatting code... +ifneq ($(HAS_SERVER),) + @echo Formatting Go files... + $(GO) fmt ./... +endif + @echo Formatting Markdown files... + @if command -v prettier > /dev/null 2>&1; then \ + prettier --write "**/*.md"; \ + elif command -v npx > /dev/null 2>&1; then \ + npx --yes prettier --write "**/*.md"; \ + else \ + echo "Warning: prettier not found. Install it with 'npm install -g prettier' to format Markdown files."; \ + fi + @echo Formatting complete. + .PHONY: logs logs: ./build/bin/pluginctl logs $(PLUGIN_ID) @@ -287,6 +309,132 @@ logs: logs-watch: ./build/bin/pluginctl logs-watch $(PLUGIN_ID) +# Development environment management with Podman Compose +PODMAN_COMPOSE_FILE ?= podman-compose.yml +PODMAN_COMPOSE := podman-compose -f $(PODMAN_COMPOSE_FILE) + +## Sets up the required directories for Podman development stack. +.PHONY: dev-setup +dev-setup: + @echo "Setting up development environment directories..." + @mkdir -p podman/data/mattermost/plugins podman/data/mattermost/client/plugins podman/data/postgres podman/config + @chmod -R 777 podman/config podman/data/mattermost 2>/dev/null || true + @echo "Directories created and permissions set." + +## Starts the Podman Compose development stack. +.PHONY: dev-up +dev-up: dev-setup + @echo "Starting development environment..." + $(PODMAN_COMPOSE) up -d + @echo "Waiting for Mattermost to be ready..." + @timeout 60 bash -c 'until $$(curl -s http://localhost:8065/api/v4/system/ping > /dev/null 2>&1); do sleep 2; done' || echo "Mattermost is starting. Access at http://localhost:8065" + @echo "Creating admin account if it doesn't exist..." + @$(MAKE) dev-create-admin || echo "Admin account already exists or creation failed" + +## Stops the Podman Compose development stack. +.PHONY: dev-down +dev-down: + @echo "Stopping development environment..." + $(PODMAN_COMPOSE) down + +## Creates the admin account if it doesn't exist. +## Uses credentials from podman-compose.yml (admin/admin123/admin@example.com) +## Also creates a default team and adds the admin user to it. +.PHONY: dev-create-admin +dev-create-admin: + @./scripts/dev-create-admin.sh + +## Alias for dev-up +.PHONY: dev-start +dev-start: dev-up + +## Alias for dev-down +.PHONY: dev-stop +dev-stop: dev-down + +## Restarts the Podman Compose development stack. +.PHONY: dev-restart +dev-restart: dev-down dev-up + +## Removes containers, volumes, and data for a clean start. +.PHONY: dev-clean +dev-clean: + @echo "Cleaning development environment (removing containers, volumes, and data)..." + $(PODMAN_COMPOSE) down -v + sudo rm -rf podman/data/* podman/config/* + @echo "Development environment cleaned. Run 'make dev-up' to start fresh." + +## Views Mattermost server logs. +.PHONY: dev-logs +dev-logs: + $(PODMAN_COMPOSE) logs mattermost + +## Tails Mattermost server logs in real-time. +.PHONY: dev-logs-watch +dev-logs-watch: + $(PODMAN_COMPOSE) logs -f mattermost + +## Builds and deploys the plugin to the Podman development stack. +.PHONY: dev-deploy +dev-deploy: dist + @PLUGIN_ID=$(PLUGIN_ID) BUNDLE_NAME=dist/$(BUNDLE_NAME) ./scripts/dev-deploy.sh + +## Opens a shell in the Mattermost container. +.PHONY: dev-shell +dev-shell: + $(PODMAN_COMPOSE) exec mattermost /bin/sh + +## Shows status of Podman Compose services. +.PHONY: dev-status +dev-status: + $(PODMAN_COMPOSE) ps + +## Enables the plugin in the development environment. +.PHONY: dev-enable +dev-enable: + @./scripts/dev-check-container.sh + @MM_SERVICESETTINGS_SITEURL=http://localhost:8065 \ + ./build/bin/pluginctl enable $(PLUGIN_ID) + +## Disables the plugin in the development environment. +.PHONY: dev-disable +dev-disable: + @./scripts/dev-check-container.sh + @MM_SERVICESETTINGS_SITEURL=http://localhost:8065 \ + ./build/bin/pluginctl disable $(PLUGIN_ID) + +## Resets the plugin in the development environment (disables and re-enables). +.PHONY: dev-reset +dev-reset: + @./scripts/dev-check-container.sh + @MM_SERVICESETTINGS_SITEURL=http://localhost:8065 \ + ./build/bin/pluginctl reset $(PLUGIN_ID) + +## Views plugin logs in the development environment. +.PHONY: dev-plugin-logs +dev-plugin-logs: + @./scripts/dev-check-container.sh + @MM_SERVICESETTINGS_SITEURL=http://localhost:8065 \ + ./build/bin/pluginctl logs $(PLUGIN_ID) + +## Tails plugin logs in the development environment. +.PHONY: dev-plugin-logs-watch +dev-plugin-logs-watch: + @./scripts/dev-check-container.sh + @MM_SERVICESETTINGS_SITEURL=http://localhost:8065 \ + ./build/bin/pluginctl logs-watch $(PLUGIN_ID) + +## Pings the Mattermost healthcheck endpoint to verify server is responding. +.PHONY: dev-check-ping +dev-check-ping: + @echo "Checking Mattermost healthcheck endpoint..." + @if $(CURL) -f -s http://localhost:8065/api/v4/system/ping > /dev/null 2>&1; then \ + echo "✓ Mattermost is responding (http://localhost:8065/api/v4/system/ping)"; \ + else \ + echo "✗ Mattermost is not responding. Is it running? (Try 'make dev-up')"; \ + exit 1; \ + fi + # Help documentation à la https://marmelab.com/blog/2016/02/29/auto-documented-makefile.html help: @cat Makefile build/*.mk | grep -v '\.PHONY' | grep -v '\help:' | grep -B1 -E '^[a-zA-Z0-9_.-]+:.*' | sed -e "s/:.*//" | sed -e "s/^## //" | grep -v '\-\-' | sed '1!G;h;$$!d' | awk 'NR%2{printf "\033[36m%-30s\033[0m",$$0;next;}1' | sort diff --git a/README.md b/README.md index 5ecca09..ea80525 100644 --- a/README.md +++ b/README.md @@ -6,19 +6,19 @@ This plugin allows you to manage multiple settings relating to preventing spam a The plugin has the following features: -* Censor/filter posts on the server (including during editing) to either reject or censor unwanted words (e.g., profanity) - * Words can be replaced with a series of characters (e.g., "\*"), or rejected outright with a message to the user -* Automatically deactivate users (cancel registration) if their username matches list of unwanted names -* Automatically deactivate users (cancel registration) if their email matches list of unwanted domains/addresses -* Prevent new users from sending direct messages to other users for some time period +- Censor/filter posts on the server (including during editing) to either reject or censor unwanted words (e.g., profanity) + - Words can be replaced with a series of characters (e.g., "\*"), or rejected outright with a message to the user +- Automatically deactivate users (cancel registration) if their username matches list of unwanted names +- Automatically deactivate users (cancel registration) if their email matches list of unwanted domains/addresses +- Prevent new users from sending direct messages to other users for some time period In the future, this plugin will: -* Send notifications to a centralized channel of moderation actions taken -* Allow moderators to restore accounts, perform inquiries on users, see the history of the account and its changes -* Grant "trust" levels to users based on the account status and optional moderator input - * e.g., allow accounts in a certain LDAP group to bypass checks -* Be a hub for all community operations activities--moderation and otherwise +- Send notifications to a centralized channel of moderation actions taken +- Allow moderators to restore accounts, perform inquiries on users, see the history of the account and its changes +- Grant "trust" levels to users based on the account status and optional moderator input + - e.g., allow accounts in a certain LDAP group to bypass checks +- Be a hub for all community operations activities--moderation and otherwise **Supported Mattermost Server Versions: 9.3+** @@ -45,6 +45,6 @@ In addition to the Bad Word List, a Bad Domain and Bad Username list is availabl Want to help improve the Mattermost Community Toolkit Plugin? Please see our [Contributing Guide](CONTRIBUTING.md) for detailed information on: -* Development environment setup -* Build system and make commands -* Debugging and troubleshooting +- Development environment setup +- Build system and make commands +- Debugging and troubleshooting diff --git a/build/manifest/main.go b/build/manifest/main.go index e3c9ddf..ad22988 100644 --- a/build/manifest/main.go +++ b/build/manifest/main.go @@ -1,9 +1,13 @@ +// Package main provides a build tool for managing Mattermost plugin manifests. +// It handles manifest discovery, version generation, and code generation for +// server and webapp components. package main import ( "encoding/json" "fmt" "os" + "path/filepath" "strings" "github.com/mattermost/mattermost/server/public/model" @@ -96,11 +100,13 @@ func findManifest() (*model.Manifest, error) { if err != nil { return nil, errors.Wrap(err, "failed to find manifest in current working directory") } + // Sanitize the file path to prevent path traversal attacks + manifestFilePath = filepath.Clean(manifestFilePath) manifestFile, err := os.Open(manifestFilePath) if err != nil { return nil, errors.Wrapf(err, "failed to open %s", manifestFilePath) } - defer manifestFile.Close() + defer func() { _ = manifestFile.Close() }() // Re-decode the manifest, disallowing unknown fields. When we write the manifest back out, // we don't want to accidentally clobber anything we won't preserve. diff --git a/build/pluginctl/logs.go b/build/pluginctl/logs.go index f20e8bb..8fcba8a 100644 --- a/build/pluginctl/logs.go +++ b/build/pluginctl/logs.go @@ -178,7 +178,7 @@ func checkJSONLogsSetting(ctx context.Context, client *model.Client4) error { return fmt.Errorf("failed to fetch config: %w", err) } if cfg.LogSettings.FileJson == nil || !*cfg.LogSettings.FileJson { - return errors.New("JSON output for file logs are disabled. Please enable LogSettings.FileJson via the configration in Mattermost.") //nolint:revive,stylecheck + return errors.New("JSON output for file logs are disabled. Please enable LogSettings.FileJson via the configuration in Mattermost") //nolint:revive,stylecheck } return nil diff --git a/build/pluginctl/main.go b/build/pluginctl/main.go index 2f80af5..aff0395 100644 --- a/build/pluginctl/main.go +++ b/build/pluginctl/main.go @@ -8,6 +8,7 @@ import ( "log" "net" "os" + "path/filepath" "time" "github.com/mattermost/mattermost/server/public/model" @@ -125,11 +126,13 @@ func getUnixClient(socketPath string) (*model.Client4, bool) { // deploy attempts to upload and enable a plugin via the Client4 API. // It will fail if plugin uploads are disabled. func deploy(ctx context.Context, client *model.Client4, pluginID, bundlePath string) error { + // Sanitize the file path to prevent path traversal attacks + bundlePath = filepath.Clean(bundlePath) pluginBundle, err := os.Open(bundlePath) if err != nil { return fmt.Errorf("failed to open %s: %w", bundlePath, err) } - defer pluginBundle.Close() + defer func() { _ = pluginBundle.Close() }() log.Print("Uploading plugin via API.") _, _, err = client.UploadPluginForced(ctx, pluginBundle) diff --git a/docs/NEW_USER_MODERATION.md b/docs/NEW_USER_MODERATION.md new file mode 100644 index 0000000..e641e8c --- /dev/null +++ b/docs/NEW_USER_MODERATION.md @@ -0,0 +1,554 @@ +# New User Moderation Features + +## Overview + +The Community Toolkit Plugin provides comprehensive moderation features to help protect your Mattermost community from spam, abuse, and disruptive behavior by new users. This document describes the new user restriction features that allow administrators to temporarily (or permanently) limit what newly registered users can do. + +These features are designed to provide a "trust building" period where new users must participate in public channels before gaining full access to all communication features. + +## Features + +The plugin offers three independent moderation controls for new users: + +1. **Direct Message (DM) Blocking** - Prevents new users from sending private/direct messages +2. **Link Blocking** - Prevents new users from posting URLs and links +3. **Image Blocking** - Prevents new users from posting images and media + +Each feature can be: + +- Enabled or disabled independently +- Configured with different time durations +- Set to block indefinitely + +## Why Use New User Moderation? + +### Common Use Cases + +**Anti-Spam Protection** + +- Prevents spammers from mass-DMing users +- Stops link spam in channels +- Blocks image-based spam and phishing + +**Community Onboarding** + +- Encourages new users to participate in public channels first +- Allows moderators to observe new user behavior +- Builds trust before granting full access + +**High-Security Environments** + +- Protects sensitive channels from social engineering +- Reduces phishing attack vectors +- Provides time to verify new user legitimacy + +**Public Communities** + +- Reduces harassment via DMs +- Prevents drive-by link spam +- Deters trolls and bad actors + +## Configuration Guide + +All settings are configured through the Mattermost System Console under: +**System Console → Plugins → Community Toolkit** + +### Direct Message Blocking + +**Setting: Block New User PMs** + +- Type: Boolean (checkbox) +- Default: Disabled +- Description: When enabled, prevents new users from sending direct messages for the configured duration + +**Setting: Block New User PM Time** + +- Type: Text (duration string) +- Default: `24h` +- Description: How long to block DMs after account creation +- Special values: + - `-1` = Block indefinitely (user can never send DMs) + - Empty = Feature disabled + +**Duration Format Examples:** + +- `1h` = 1 hour +- `24h` = 24 hours (1 day) +- `168h` = 168 hours (7 days) +- `12h30m` = 12 hours and 30 minutes +- `-1` = Indefinite (permanent block) + +### Link Blocking + +**Setting: Block New User Links** + +- Type: Boolean (checkbox) +- Default: Disabled +- Description: When enabled, prevents new users from posting links for the configured duration + +**Setting: Block New User Links Time** + +- Type: Text (duration string) +- Default: `24h` +- Description: How long to block link posts after account creation +- Format: Same as DM blocking (see above) + +**What Counts as a Link:** + +- URLs starting with `http://` or `https://` +- URLs starting with `www.` +- Posts with OpenGraph link previews +- Markdown-formatted links + +**Note:** Plain domain names without protocol (e.g., "example.com") are NOT detected as links. + +### Image Blocking + +**Setting: Block New User Images** + +- Type: Boolean (checkbox) +- Default: Disabled +- Description: When enabled, prevents new users from posting images for the configured duration + +**Setting: Block New User Images Time** + +- Type: Text (duration string) +- Default: `24h` +- Description: How long to block image posts after account creation +- Format: Same as DM blocking (see above) + +**What Counts as an Image:** + +- File attachments with image extensions (jpg, jpeg, png, gif, bmp, webp, svg, tiff, ico, heic, heif, avif) +- Embedded images from URLs +- Markdown-formatted images (`![alt](url)`) + +## Configuration Examples + +### Conservative Setup (Recommended for Most Communities) + +Blocks new users from potentially disruptive actions for 24 hours: + +``` +Block New User PMs: ✓ Enabled +Block New User PM Time: 24h + +Block New User Links: ✓ Enabled +Block New User Links Time: 24h + +Block New User Images: ✓ Enabled +Block New User Images Time: 24h +``` + +**Effect:** New users can participate in public channels for 24 hours before gaining ability to send DMs, post links, or share images. + +### Strict Setup (High-Security Communities) + +Blocks new users for 7 days to allow thorough vetting: + +``` +Block New User PMs: ✓ Enabled +Block New User PM Time: 168h + +Block New User Links: ✓ Enabled +Block New User Links Time: 168h + +Block New User Images: ✓ Enabled +Block New User Images Time: 168h +``` + +**Effect:** New users must participate for a full week before gaining full access. + +### Maximum Security (Permanent Restrictions) + +Permanently blocks all new users from certain actions: + +``` +Block New User PMs: ✓ Enabled +Block New User PM Time: -1 + +Block New User Links: ✓ Enabled +Block New User Links Time: -1 + +Block New User Images: ✓ Enabled +Block New User Images Time: -1 +``` + +**Effect:** New users can NEVER send DMs, post links, or share images. This effectively creates a "read-only" user base where only manually promoted users have full access. + +**Warning:** This configuration is very restrictive. Consider using a trust level system or manual promotion process. + +### Mixed Approach (Graduated Access) + +Different durations for different features: + +``` +Block New User PMs: ✓ Enabled +Block New User PM Time: 168h (7 days for DMs) + +Block New User Links: ✓ Enabled +Block New User Links Time: 24h (1 day for links) + +Block New User Images: ✓ Enabled +Block New User Images Time: 48h (2 days for images) +``` + +**Effect:** New users gain features gradually - links after 1 day, images after 2 days, DMs after 7 days. + +### Anti-Spam Only (Links and Images) + +Blocks spam vectors but allows DMs: + +``` +Block New User PMs: ✗ Disabled + +Block New User Links: ✓ Enabled +Block New User Links Time: 48h + +Block New User Images: ✓ Enabled +Block New User Images Time: 48h +``` + +**Effect:** Prevents link/image spam but allows legitimate users to communicate via DM. + +## User Experience + +### What Users See + +When a new user attempts a blocked action, they receive an ephemeral message (only visible to them) explaining why their action was blocked: + +**For Direct Messages:** + +> "Configuration settings limit new users from sending private messages." + +**For Links:** + +> "Configuration settings limit new users from posting links." + +**For Images:** + +> "Configuration settings limit new users from posting images." + +The post is not created, and no notification is sent to other users. + +### User Timeline + +Here's what a new user experiences with the "Conservative Setup" (24h blocks): + +**Hour 0 (Account Creation):** + +- ✓ Can view all public channels +- ✓ Can post text messages in public channels +- ✗ Cannot send direct messages +- ✗ Cannot post links +- ✗ Cannot post images + +**Hour 24 (After 24 hours):** + +- ✓ All restrictions lifted automatically +- ✓ Can now send DMs, post links, and share images + +**No manual intervention required** - restrictions lift automatically based on account age. + +## Technical Details + +### How Detection Works + +**Link Detection:** + +1. Checks for OpenGraph embeds in post metadata (most reliable) +2. Uses regex pattern to detect URLs: `https?://[^\s<>"]+|www\.[^\s<>"]+` +3. Matches in post message content + +**Image Detection:** + +1. Checks file attachments for image extensions (case-insensitive) +2. Checks for image embeds in post metadata +3. Detects Markdown image syntax: `![alt](url)` + +**Supported Image Formats:** + +- Common: jpg, jpeg, png, gif, bmp +- Modern: webp, avif, heic, heif +- Other: svg, tiff, tif, ico + +**User Age Calculation:** + +- Based on user's `CreateAt` timestamp from Mattermost database +- Calculated at time of post attempt +- Uses Go's `time.ParseDuration()` for parsing duration strings + +### Duration Format Specification + +The duration format follows Go's `time.ParseDuration()` standard: + +**Valid Units:** + +- `ns` = nanoseconds +- `us` or `µs` = microseconds +- `ms` = milliseconds +- `s` = seconds +- `m` = minutes +- `h` = hours + +**Valid Formats:** + +- Single unit: `24h`, `60m`, `3600s` +- Multiple units: `1h30m`, `2h45m30s` +- Decimal notation: `1.5h` (90 minutes) +- Special value: `-1` (indefinite) + +**Invalid Formats:** + +- Days: `7d` ❌ (use `168h` instead) +- Weeks: `1w` ❌ (use `168h` for 1 week) +- Spaces: `24 hours` ❌ (use `24h`) +- Text: `one day` ❌ + +**Configuration Validation:** +The plugin validates duration formats when configuration changes. Invalid formats will prevent the configuration from being saved and show an error message. + +### Performance Considerations + +**Caching:** + +- User objects are cached in a 50-entry LRU cache +- Reduces database queries for frequently active users +- Cache hit rate typically >90% in active channels + +**Regex Patterns:** + +- Compiled once when configuration changes +- Reused for all post checks (no per-post compilation cost) +- Minimal performance impact on posting + +**Recommended Limits:** + +- Plugin handles thousands of posts per second +- No significant performance impact observed +- Safe for communities of any size + +## Troubleshooting + +### Common Issues + +**Issue: New users are not being blocked** + +**Solutions:** + +1. Verify the feature is enabled (checkbox is checked) +2. Check that duration is set (not empty) +3. Ensure duration format is valid (e.g., `24h`, not `24 hours`) +4. Check plugin logs for errors +5. Verify plugin is active and enabled + +**Issue: Old users are being blocked** + +**Solutions:** + +1. Check if duration is set to `-1` (indefinite blocking affects ALL users) +2. Verify user creation date in Mattermost database +3. Check for clock sync issues on server +4. Review plugin cache (may need plugin restart) + +**Issue: Links are not being detected** + +**Reasons:** + +1. Plain domain names without protocol are not detected (by design) +2. Obfuscated URLs may bypass detection +3. Links in code blocks are still detected (feature limitation) + +**Solutions:** + +- Educate users that `www.example.com` IS detected +- Consider using word filtering for domain names +- Report bypass techniques to plugin maintainers + +**Issue: Configuration won't save** + +**Causes:** + +1. Invalid duration format +2. Plugin configuration error +3. Insufficient permissions + +**Solutions:** + +1. Verify duration format (see "Duration Format Specification") +2. Check Mattermost logs for plugin errors +3. Ensure you have System Admin permissions +4. Try disabling and re-enabling the plugin + +### Checking if Features Are Working + +**Test Link Blocking:** + +1. Create a test user account +2. Immediately try posting a link: `https://example.com` +3. Should receive blocking message + +**Test Image Blocking:** + +1. Create a test user account +2. Immediately try uploading an image file +3. Should receive blocking message + +**Test DM Blocking:** + +1. Create a test user account +2. Immediately try sending a DM to another user +3. Should receive blocking message + +**Verify Duration:** + +1. Note the time you create the test account +2. Wait for the configured duration to pass +3. Try the action again - should now be allowed + +### Debug Commands + +**Check Plugin Status:** + +```bash +curl -H "Authorization: Bearer $ADMIN_TOKEN" \ + $SITEURL/api/v4/plugins | jq '.active' +``` + +**View Plugin Logs:** + +```bash +# If using pluginctl +make logs + +# Or via Mattermost logs +tail -f /opt/mattermost/logs/mattermost.log | grep community-toolkit +``` + +**Check User Creation Time:** + +```sql +-- In Mattermost database +SELECT Id, Username, CreateAt, FROM_UNIXTIME(CreateAt/1000) as CreatedDate +FROM Users +WHERE Username = 'test_user'; +``` + +## Best Practices + +### Recommended Configurations + +**For Public Communities:** + +- Start with 24h blocks on all features +- Monitor for false positives +- Adjust durations based on community culture + +**For Private/Corporate Instances:** + +- Consider shorter durations (1-4 hours) +- Or disable DM blocking entirely +- Focus on link/image spam prevention + +**For High-Risk Communities:** + +- Use 7-day (168h) blocks +- Enable email domain filtering +- Combine with username validation + +### Communication Strategy + +**Set Expectations:** + +- Document restrictions in welcome messages +- Update registration email/onboarding materials +- Post rules in welcome channel + +**Example Welcome Message:** + +> "Welcome to our community! To prevent spam, new accounts have temporary restrictions: +> +> - You can post in public channels immediately +> - After 24 hours, you can send direct messages and post links/images +> +> Thank you for your patience as we keep our community safe!" + +**Monitor User Feedback:** + +- Watch for complaints from legitimate users +- Adjust durations if onboarding is too restrictive +- Consider graduated access approach + +### Security Considerations + +**Defense in Depth:** + +- Don't rely solely on new user restrictions +- Combine with email domain filtering +- Use username validation +- Enable built-in profanity filter + +**Legitimate User Impact:** + +- Shorter durations (1-24h) minimize frustration +- Avoid indefinite blocks unless necessary +- Consider manual "verified user" process for trusted accounts + +**Bypass Considerations:** + +- Attackers may create accounts in advance (age them) +- Monitor for coordinated attacks from multiple aged accounts +- Supplement with rate limiting and behavior analysis + +## Future Enhancements + +The following features are planned for future releases: + +- **Trust Levels:** Manual promotion system for verified users +- **LDAP Integration:** Bypass restrictions for LDAP/SSO users +- **Graduated Permissions:** Fine-grained control over feature access +- **Moderation Dashboard:** UI for viewing blocked attempts +- **Analytics:** Reports on blocked content and users +- **Custom Messages:** Configurable user-facing messages per restriction + +## Support and Feedback + +For issues, feature requests, or questions: + +- GitHub Issues: [mattermost-plugin-community-toolkit/issues](https://github.com/rocky-linux/mattermost-plugin-community-toolkit/issues) +- Documentation: See main README.md +- Community: Mattermost Community Server + +## Appendix: Configuration Reference + +### Complete Settings Matrix + +| Setting | Type | Default | Valid Values | Description | +| ---------------------- | ------- | ------- | ---------------- | --------------------- | +| BlockNewUserPM | Boolean | false | true/false | Enable DM blocking | +| BlockNewUserPMTime | String | "24h" | duration or "-1" | DM block duration | +| BlockNewUserLinks | Boolean | false | true/false | Enable link blocking | +| BlockNewUserLinksTime | String | "24h" | duration or "-1" | Link block duration | +| BlockNewUserImages | Boolean | false | true/false | Enable image blocking | +| BlockNewUserImagesTime | String | "24h" | duration or "-1" | Image block duration | + +### Duration Conversion Chart + +| Human Readable | Duration String | +| ------------------- | --------------- | +| 1 hour | `1h` or `60m` | +| 6 hours | `6h` | +| 12 hours | `12h` | +| 24 hours (1 day) | `24h` | +| 48 hours (2 days) | `48h` | +| 72 hours (3 days) | `72h` | +| 168 hours (1 week) | `168h` | +| 336 hours (2 weeks) | `336h` | +| 720 hours (30 days) | `720h` | +| Indefinite | `-1` | + +--- + +**Document Version:** 1.0 +**Last Updated:** 2025-11-05 +**Compatible with:** Community Toolkit Plugin v2.0.7+ diff --git a/docs/PODMAN_DEVELOPMENT.md b/docs/PODMAN_DEVELOPMENT.md new file mode 100644 index 0000000..74a63ca --- /dev/null +++ b/docs/PODMAN_DEVELOPMENT.md @@ -0,0 +1,380 @@ +# Podman Development Environment + +This document describes how to use the Podman Compose-based development environment for testing the Mattermost Community Toolkit plugin locally. + +## Overview + +The development environment provides a complete, isolated Mattermost instance with PostgreSQL, suitable for testing plugin functionality without affecting a production server. + +## Prerequisites + +- Podman and Podman Compose installed +- Make utility (standard on Linux/macOS) +- Network access to pull container images + +## Quick Start + +1. **Start the development environment:** + + ```bash + make dev-up + ``` + +2. **Wait for Mattermost to be ready:** + The `dev-up` target will wait up to 60 seconds for Mattermost to become ready. You can access it at http://localhost:8065 + +3. **First-time setup:** + - Open http://localhost:8065 in your browser + - Create your admin account (or use the credentials from `podman/env.example`) + - Complete the initial setup wizard + +4. **Configure plugin deployment credentials:** + + You need to set environment variables for plugin deployment. You can either: + + **Option A: Use an admin token (recommended)** + + ```bash + export MM_ADMIN_TOKEN="your-admin-token-here" + ``` + + To get a token: + + - Log into Mattermost + - Go to Menu > System Console > Integrations > Bot Accounts + - Choose to enable bot accounts + - Go to Menu > Integrations > Bot Accounts + - Add a Bot Account for the plugin (gives us a token to use for auth) + - Bot Account will need role: `system admin` + + **Option B: Use username/password** + + ```bash + export MM_ADMIN_USERNAME="admin" + export MM_ADMIN_PASSWORD="your-password" + ``` + +5. **Deploy the plugin:** + + ```bash + make dev-deploy + ``` + +6. **Enable the plugin:** + + ```bash + make dev-enable + ``` + + Or enable it via System Console > Plugins > Community Toolkit > Enable + +7. **Test your plugin:** + - Access Mattermost at http://localhost:8065 + - Create test users and channels + - Test plugin functionality + +## Make Targets + +### Environment Management + +- **`make dev-up`** / **`make dev-start`** - Start the Podman Compose stack + - Starts PostgreSQL and Mattermost containers + - Waits for Mattermost to be ready + - Data persists between restarts + +- **`make dev-down`** / **`make dev-stop`** - Stop the stack + - Stops containers but preserves data + - Use this for daily development (faster than clean) + +- **`make dev-restart`** - Restart the stack + - Equivalent to `dev-down` followed by `dev-up` + +- **`make dev-clean`** - Clean everything + - Stops containers + - Removes volumes and all data + - Use this when you want a completely fresh start + - **Warning:** This deletes all Mattermost data, users, and settings + +### Plugin Management + +- **`make dev-deploy`** - Build and deploy plugin + - Builds the plugin bundle + - Uploads to the running Mattermost instance + - Requires `MM_ADMIN_TOKEN` or `MM_ADMIN_USERNAME`/`MM_ADMIN_PASSWORD` to be set + +- **`make dev-enable`** - Enable the plugin + - Enables the plugin in the development environment + - Verifies Mattermost container is running + - Loads credentials from `podman/.env` if present + +- **`make dev-disable`** - Disable the plugin + - Disables the plugin in the development environment + - Verifies Mattermost container is running + +- **`make dev-reset`** - Reset the plugin + - Disables and re-enables the plugin (useful for testing) + - Verifies Mattermost container is running + +### Debugging and Monitoring + +- **`make dev-logs`** - View Mattermost server logs + - Shows recent logs from the Mattermost container + +- **`make dev-logs-watch`** - Tail logs in real-time + - Follows Mattermost logs as they are generated + - Useful for debugging plugin issues + - Press Ctrl+C to stop + +- **`make dev-plugin-logs`** - View plugin logs + - Shows plugin-specific logs from the development environment + - Useful for debugging plugin behavior + +- **`make dev-plugin-logs-watch`** - Tail plugin logs in real-time + - Follows plugin logs as they are generated + - Useful for real-time plugin debugging + - Press Ctrl+C to stop + +- **`make dev-shell`** - Open shell in Mattermost container + - Provides interactive shell access for debugging + - Useful for inspecting files, checking processes, etc. + +- **`make dev-status`** - Show container status + - Lists running containers and their status + - Quick way to verify the stack is running + +## Configuration + +### Environment Variables + +The Podman Compose setup uses environment variables from: + +1. `podman/.env` file (if it exists) +2. Shell environment variables +3. Default values in `podman-compose.yml` + +To customize the environment: + +1. Copy the example file: + + ```bash + cp podman/env.example podman/.env + ``` + +2. Edit `podman/.env` with your preferences: + - Database credentials + - Admin user credentials + - Site URL (default: http://localhost:8065) + +3. The `.env` file is gitignored, so your local settings won't be committed. + +### Default Configuration + +- **Site URL:** http://localhost:8065 +- **Database:** PostgreSQL 14 +- **Mattermost Version:** 9.3 (minimum required for plugin) +- **Plugin Uploads:** Enabled +- **Developer Mode:** Enabled (for debugging) + +## Common Workflows + +### Daily Development Workflow + +```bash +# Start environment (if not already running) +make dev-up + +# Make code changes... + +# Rebuild and redeploy plugin +make dev-deploy + +# Enable plugin if needed +make dev-enable + +# View logs to check for issues +make dev-logs-watch + +# Test in browser at http://localhost:8065 +``` + +### Clean Start Workflow + +```bash +# Stop and clean everything +make dev-clean + +# Start fresh +make dev-up + +# Set up Mattermost (first-time setup) +# Open http://localhost:8065 in browser + +# Configure deployment credentials +export MM_ADMIN_TOKEN="your-token" + +# Deploy and enable plugin +make dev-deploy +make dev-enable +``` + +### Troubleshooting Permission Issues + +The setup process automatically creates the required directories with proper permissions via the `dev-setup` target. However, if you encounter permission issues with the config or data directories, you can manually fix them: + +```bash +chmod -R 777 podman/config podman/data/mattermost +``` + +### Debugging Workflow + +```bash +# View logs +make dev-logs-watch + +# Check container status +make dev-status + +# Access container shell +make dev-shell + +# Inside the shell, you can: +# - Check plugin files: ls -la /mattermost/plugins/ +# - View Mattermost config: cat /mattermost/config/config.json +# - Check logs: tail -f /mattermost/logs/mattermost.log +``` + +## Troubleshooting + +### Mattermost Won't Start + +**Symptoms:** `make dev-up` completes but Mattermost isn't accessible + +**Solutions:** + +1. Check container status: `make dev-status` +2. View logs: `make dev-logs` +3. Check if port 8065 is already in use: + ```bash + lsof -i :8065 + ``` +4. Restart the stack: `make dev-restart` + +### Plugin Deployment Fails + +**Symptoms:** `make dev-deploy` fails with authentication error + +**Solutions:** + +1. Verify credentials are set: + ```bash + echo $MM_ADMIN_TOKEN + # or + echo $MM_ADMIN_USERNAME + ``` +2. Check if Mattermost is ready: + ```bash + curl http://localhost:8065/api/v4/system/ping + ``` +3. Verify admin user exists and credentials are correct +4. Generate a new token from System Console if needed + +### Database Connection Issues + +**Symptoms:** Mattermost logs show database connection errors + +**Solutions:** + +1. Check PostgreSQL container is running: `make dev-status` +2. Restart the stack: `make dev-restart` +3. If issues persist, try a clean start: `make dev-clean` then `make dev-start` + +### Port Already in Use + +**Symptoms:** Podman Compose fails with "port already allocated" + +**Solutions:** + +1. Find what's using the port: + ```bash + lsof -i :8065 + ``` +2. Stop the conflicting service, or +3. Modify `podman-compose.yml` to use a different port: + ```yaml + ports: + - "8066:8065" # Use 8066 instead of 8065 + ``` + Then update `MM_SERVICESETTINGS_SITEURL` accordingly + +### Plugin Not Loading + +**Symptoms:** Plugin appears uploaded but doesn't activate + +**Solutions:** + +1. Check plugin compatibility with Mattermost version +2. View logs for errors: `make dev-logs` +3. Verify plugin was built correctly: `make dist` +4. Check plugin permissions in container: + ```bash + make dev-shell + ls -la /mattermost/plugins/ + ``` + +### Data Persistence Issues + +**Symptoms:** Changes disappear after restart + +**Solutions:** + +1. Verify volumes are mounted: `podman volume ls` +2. Check data directories exist: `ls -la podman/data/` +3. Ensure you're using `dev-down` (not `dev-clean`) for normal stops + +## File Structure + +``` +. +├── podman-compose.yml # Podman Compose configuration +├── podman/ +│ ├── env.example # Example environment variables +│ ├── .env # Local environment overrides (gitignored) +│ ├── config/ # Mattermost config files (gitignored) +│ └── data/ # Persistent data (gitignored) +│ ├── mattermost/ # Mattermost data and plugins +│ └── postgres/ # PostgreSQL data +``` + +## Accessing Mattermost + +- **Web UI:** http://localhost:8065 +- **API:** http://localhost:8065/api/v4 +- **Admin Console:** System Console (access via hamburger menu when logged in as admin) + +## Integration with Existing Workflow + +The Podman development environment works alongside existing deployment methods: + +- **`make deploy`** - Deploys to server configured via environment variables (works with any Mattermost instance) +- **`make dev-deploy`** - Specifically deploys to the local Podman stack + +You can use either method depending on your needs. The Podman stack is ideal for: + +- Isolated testing +- CI/CD pipelines +- Reproducible test environments +- Development without affecting production + +## Best Practices + +1. **Use `dev-down` for daily stops** - Preserves your test data and configuration +2. **Use `dev-clean` sparingly** - Only when you need a completely fresh environment +3. **Set up environment variables** - Create `podman/.env` for consistent configuration +4. **Monitor logs during development** - Keep `make dev-logs-watch` running in a separate terminal +5. **Version control** - Don't commit `podman/.env` or `podman/data/` (already in `.gitignore`) + +## Additional Resources + +- [Mattermost Plugin Development Documentation](https://developers.mattermost.com/integrate/plugins/) +- [Podman Compose Documentation](https://github.com/containers/podman-compose) +- [Mattermost Docker Hub](https://hub.docker.com/r/mattermost/mattermost-team-edition) diff --git a/docs/test-plans/moderation-features-test-plan.md b/docs/test-plans/moderation-features-test-plan.md new file mode 100644 index 0000000..6056416 --- /dev/null +++ b/docs/test-plans/moderation-features-test-plan.md @@ -0,0 +1,1073 @@ +# Release Validation Test Plan + +## Purpose + +This document provides manual release validation tests for the Mattermost Community Toolkit plugin. These tests are designed to verify that all moderation features work correctly and are suitable for someone new to Mattermost. + +**Note:** This test plan covers high-level validation tests only. Comprehensive depth testing is covered by automated test suites. + +## Prerequisites + +- Local development environment set up and running (see `docs/PODMAN_DEVELOPMENT.md`) +- Mattermost accessible at `http://localhost:8065` +- Plugin installed and enabled +- Admin access to System Console +- Basic familiarity with command line +- Podman Compose installed (for database access) + +--- + +## Feature Overview + +The Community Toolkit plugin provides six main moderation features: + +### 1. Bad Word Filtering + +Filters posts containing profanity or offensive words. Can operate in two modes: + +- **Censor Mode** (default): Replaces bad words with censor characters (e.g., `****`) +- **Reject Mode**: Completely blocks the post and shows a warning message + +**Key Configuration Options:** + +- Bad Words List: Comma-separated list of words/patterns (supports regex) +- Reject Posts: Toggle between censor and reject modes +- Censor Character: Character(s) to use for replacement (default: `*`) +- Warning Message: Custom message shown when posts are rejected +- Exclude Bots: Option to skip filtering for bot messages + +### 2. New User Direct Message Blocking + +Prevents newly registered users from sending direct/private messages for a configured time period. + +**Key Configuration Options:** + +- Block New User PMs: Enable/disable feature +- Block New User PM Time: Duration (e.g., `24h`, `7d`) or `-1` for indefinite + +### 3. New User Link Blocking + +Prevents newly registered users from posting URLs and links. + +**Key Configuration Options:** + +- Block New User Links: Enable/disable feature +- Block New User Links Time: Duration or `-1` for indefinite + +### 4. New User Image Blocking + +Prevents newly registered users from posting images and media files. + +**Key Configuration Options:** + +- Block New User Images: Enable/disable feature +- Block New User Images Time: Duration or `-1` for indefinite + +### 5. Username Validation + +Automatically blocks and cleans up users with inappropriate usernames or nicknames during registration. + +**Key Configuration Options:** + +- Bad Usernames: Comma-separated list of username patterns (supports regex) + +### 6. Email Domain Validation + +Automatically blocks and cleans up users registering with disposable or blocked email domains. + +**Key Configuration Options:** + +- Use Built-in Bad-Domains list: Enable built-in disposable domain list (51,501 domains) +- Bad Domains List: Additional custom domain patterns (supports regex) + +--- + +## Test Setup + +### Step 1: Start Development Environment + +```bash +# From the plugin repository root directory +make dev-up +``` + +Wait for Mattermost to be ready (usually 30-60 seconds). You can verify it's running by opening `http://localhost:8065` in your browser. + +### Step 2: Access Mattermost + +1. Open `http://localhost:8065` in your web browser +2. If this is the first time, complete the initial setup wizard to create your admin account +3. Log in with your admin credentials + +### Step 3: Install and Enable Plugin + +1. **Build the plugin:** + + ```bash + make dist + ``` + +2. **Deploy the plugin:** + + ```bash + make dev-deploy + ``` + +3. **Enable the plugin:** + - Navigate to **System Console** (click hamburger menu ☰ → System Console) + - Go to **Plugins** → **Community Toolkit** + - Click **Enable** button + - Wait for "Plugin enabled successfully" message + +### Step 4: Configure Plugin + +1. In System Console, go to **Plugins** → **Community Toolkit** +2. Configure settings as needed for each test (specific configurations will be provided in each test case) +3. Click **Save** after making changes + +--- + +## Creating Test Users + +### Method 1: Via the `mmctl` tool (Recommended) + +1. In the terminal where you started the local development environment +2. Establish a credential for using the `mmctl` command: + + ```bash + podman-compose exec mattermost bin/mmctl auth login http://localhost:8065 + ``` + + This will ask you for three pieces of information: + - Connection Name: Use anything you want here, example: `testconn` + - User Name: Use the admin user, default is: `admin` + - User Password: Use the password the admin user was setup with, default is: `admin123` + +3. Run (substitute the values for the user you want to create): + + ```bash + podman-compose exec mattermost bin/mmctl user create \ + --email test01@example.com \ + --username test01 \ + --password UserPassword123 + ``` + +**Note:** Users created this way will have a "creation date" of the current time. To test time-based restrictions, you'll need to modify the creation date in the database (see below). + +### Method 2: Via User Registration (For Email/Username Validation Tests) + +1. Navigate to **System Console** → **Authentication** → **Sign Up** +2. Ensure "Enable account creation" is enabled +3. Use the registration page at `http://localhost:8065/signup_user_complete` +4. Register with the test email/username you want to validate + +--- + +## Modifying User Creation Date (Simulating User Age) + +To test time-based restrictions (DM blocking, link blocking, image blocking), you need to simulate users of different ages. This requires modifying the `CreateAt` field in the PostgreSQL database. + +### Step 1: Access PostgreSQL Container + +```bash +# From the plugin repository root directory +podman-compose exec postgres psql -U mmuser -d mattermost +``` + +You should see a `mattermost=#` prompt. + +### Step 2: Find Your Test User + +```sql +-- List users to find the one you want to modify +SELECT id, username, email, to_timestamp(createat/1000) as created_at +FROM users +WHERE deleteat = 0 +ORDER BY createat DESC; +``` + +Look for your test user in the output. Note the `id` (a long string) and `createat` (a large number in milliseconds). + +### Step 3: Calculate New CreateAt Value + +The `CreateAt` field is stored as milliseconds since Unix epoch (January 1, 1970). + +**Examples:** + +- To set user to be created **2 hours ago** (for testing 1-hour restriction): + + ```sql + -- Current timestamp in milliseconds minus 2 hours + SELECT (EXTRACT(EPOCH FROM NOW() - INTERVAL '2 hours') * 1000)::bigint; + ``` + +- To set user to be created **25 hours ago** (for testing 24-hour restriction): + + ```sql + -- Current timestamp in milliseconds minus 25 hours + SELECT (EXTRACT(EPOCH FROM NOW() - INTERVAL '25 hours') * 1000)::bigint; + ``` + +- To set user to be created **just now** (for testing new user restrictions): + ```sql + -- Current timestamp in milliseconds + SELECT (EXTRACT(EPOCH FROM NOW()) * 1000)::bigint; + ``` + +### Step 4: Update User Creation Date + +Replace `USER_ID_HERE` with your user's ID from Step 2, and `NEW_CREATEAT_VALUE` with the calculated value: + +```sql +UPDATE users +SET createat = NEW_CREATEAT_VALUE +WHERE id = 'USER_ID_HERE'; +``` + +**Example:** Set a user to be 25 hours old: + +```sql +UPDATE users +SET createat = (EXTRACT(EPOCH FROM NOW() - INTERVAL '25 hours') * 1000)::bigint +WHERE username = 'testuser1'; +``` + +### Step 5: Verify the Change + +```sql +SELECT username, to_timestamp(createat/1000) as created_at, + NOW() - to_timestamp(createat/1000) as age +FROM users +WHERE username = 'testuser1'; +``` + +### Step 6: Exit PostgreSQL + +```sql +\q +``` + +**Important Notes:** + +- User age calculations are done at post time, so changes take effect immediately +- The plugin uses `time.Since(user.CreateAt)` to determine user age +- Always verify the user's age after modification +- If testing fails, verify the timestamp was updated correctly + +--- + +## Test Cases + +### Test Suite 1: Bad Word Filtering + +#### Test 1.1: Word Censoring (Default Mode) + +**Objective:** Verify that bad words are replaced with censor characters when Reject Posts is disabled. + +**Prerequisites:** + +- Plugin enabled +- System Console → Plugins → Community Toolkit: + - **Reject Posts**: Unchecked (disabled) + - **Censor Character**: `*` (default) + - **Bad Words List**: `testword,badword` (add simple test words) + +**Test Steps:** + +1. Log in as a regular user (not admin) +2. Navigate to any channel +3. Post a message containing a test bad word, e.g., `This is a testword message` +4. Observe the post after it appears + +**Expected Results:** + +- The bad word `testword` should be replaced with `********` (8 asterisks, one per character) +- The rest of the message should remain unchanged +- The post should appear in the channel + +**Pass/Fail Criteria:** + +- ✅ **PASS**: Bad word is censored with asterisks +- ❌ **FAIL**: Bad word appears in full, or post is rejected + +--- + +#### Test 1.2: Word Rejection Mode + +**Objective:** Verify that posts containing bad words are completely rejected when Reject Posts is enabled. + +**Prerequisites:** + +- Plugin enabled +- System Console → Plugins → Community Toolkit: + - **Reject Posts**: Checked (enabled) + - **Warning Message**: Default or custom message + - **Bad Words List**: `testword,badword` + +**Test Steps:** + +1. Log in as a regular user +2. Navigate to any channel +3. Type a message containing a bad word: `This contains testword` +4. Click **Send** (or press Enter) +5. Observe what happens + +**Expected Results:** + +- The message should NOT appear in the channel +- An ephemeral (temporary) warning message should appear, saying something like: "Your post has been rejected by the Profanity Filter, because the following word is not allowed: `testword`." +- The ephemeral message should disappear after a few seconds + +**Pass/Fail Criteria:** + +- ✅ **PASS**: Post is blocked and warning message appears +- ❌ **FAIL**: Post appears in channel, or no warning message shown + +--- + +#### Test 1.3: Bot Exclusion + +**Objective:** Verify that bot messages are not filtered when Exclude Bots is enabled. + +**Prerequisites:** + +- Plugin enabled +- System Console → Plugins → Community Toolkit: + - **Exclude Bots**: Checked (enabled) + - **Bad Words List**: `testword,badword` +- A bot account created (or use a test bot) + +**Test Steps:** + +1. As an admin, create a bot account: + - First, enable bot accounts: System Console → Integrations → Bot Accounts → Enable Bot Account Creation + - Then create the bot: System Console → Integrations → Bot Accounts → Add Bot Account + - Give it a username and display name + - After creation, create a Personal Access Token for the bot + - Save and note the bot's access token +2. Add the bot to a known channel (`Town Square` is a good choice) +3. Get your team ID first, then find the channel ID for `Town Square`: + + ```bash + # First, get your team ID (replace YOUR_TEAM_NAME with your actual team name) + curl -X GET "http://localhost:8065/api/v4/teams/name/YOUR_TEAM_NAME" \ + -H "Authorization: Bearer BOT_ACCESS_TOKEN_HERE" | jq -r '.id' + + # Then, get the channel ID using the team ID + curl -X GET "http://localhost:8065/api/v4/teams/TEAM_ID_HERE/channels/name/town-square" \ + -H "Authorization: Bearer BOT_ACCESS_TOKEN_HERE" | jq -r '.id' + ``` + + **Note:** Your team name is created during initial Mattermost setup. Common default team names include the site name or organization name you provided during setup. + +4. Using the channel ID you can post messages into the channel as the bot: + + ```bash + curl -X POST "http://localhost:8065/api/v4/posts" \ + -H "Authorization: Bearer BOT_ACCESS_TOKEN_HERE" \ + -H "Content-Type: application/json" \ + -d '{ + "channel_id": "CHANNEL_ID_GOES_HERE", + "message": "Hello from the bot! testword" + }' + ``` + + **Note:** Replace `testword` with a bad word from your configuration. + +**Expected Results:** + +- Bot message with bad word should appear uncensored +- Bot messages bypass the filter + +**Pass/Fail Criteria:** + +- ✅ **PASS**: Bot message appears with bad word uncensored +- ❌ **FAIL**: Bot message is censored or rejected + +--- + +#### Test 1.4: Case-Insensitive Matching + +**Objective:** Verify that bad word detection works regardless of capitalization. + +**Prerequisites:** + +- Plugin enabled +- **Bad Words List**: `testword` +- **Reject Posts**: Disabled (to see censoring) + +**Test Steps:** + +1. Log in as a regular user +2. Post messages with variations: + - `TESTWORD` + - `TestWord` + - `testword` + - `TeStWoRd` + +**Expected Results:** + +- All variations should be detected and censored/rejected +- Matching should be case-insensitive + +**Pass/Fail Criteria:** + +- ✅ **PASS**: All capitalization variations are filtered +- ❌ **FAIL**: Some variations bypass the filter + +--- + +#### Test 1.5: Custom Censor Character + +**Objective:** Verify that custom censor characters work correctly. + +**Prerequisites:** + +- Plugin enabled +- **Censor Character**: `X` (or another character) +- **Reject Posts**: Disabled +- **Bad Words List**: `testword` + +**Test Steps:** + +1. Log in as a regular user +2. Post a message: `This is a testword message` + +**Expected Results:** + +- Bad word should be replaced with `XXXXXXXX` (8 X's, one per character) +- Custom character is used instead of default `*` + +**Pass/Fail Criteria:** + +- ✅ **PASS**: Bad word censored with custom character +- ❌ **FAIL**: Default asterisk used, or wrong number of characters + +--- + +### Test Suite 2: New User Direct Message Blocking + +#### Test 2.1: New User Cannot Send DM + +**Objective:** Verify that a newly created user cannot send direct messages. + +**Prerequisites:** + +- Plugin enabled +- System Console → Plugins → Community Toolkit: + - **Block New User PMs**: Checked (enabled) + - **Block New User PM Time**: `24h` + +**Test Steps:** + +1. Create a new test user (created just now, no age modification needed) +2. Log in as the new test user +3. Try to send a direct message to another user: + - Click the "+" next to Direct Messages in the left sidebar + - Search for another user + - Start typing a message + - Click Send +4. Observe what happens + +**Expected Results:** + +- Message should NOT be sent +- An ephemeral warning message should appear: "Configuration settings limit new users from sending private messages." +- The DM should not appear in either user's message list + +**Pass/Fail Criteria:** + +- ✅ **PASS**: DM is blocked and warning message appears +- ❌ **FAIL**: DM is sent successfully, or no warning message + +--- + +#### Test 2.2: Older User Can Send DM + +**Objective:** Verify that users older than the configured time can send DMs. + +**Prerequisites:** + +- Plugin enabled +- **Block New User PMs**: Enabled +- **Block New User PM Time**: `24h` +- A test user that is 25+ hours old (modify creation date as described earlier) + +**Test Steps:** + +1. Log in as the older test user (created 25+ hours ago) +2. Send a direct message to another user +3. Observe if the message is sent + +**Expected Results:** + +- Message should be sent successfully +- No warning message should appear +- DM should appear in both users' message lists + +**Pass/Fail Criteria:** + +- ✅ **PASS**: DM is sent successfully without warnings +- ❌ **FAIL**: DM is blocked, or warning message appears + +--- + +#### Test 2.3: Indefinite Blocking (-1) + +**Objective:** Verify that indefinite blocking prevents DMs permanently. + +**Prerequisites:** + +- Plugin enabled +- **Block New User PMs**: Enabled +- **Block New User PM Time**: `-1` (indefinite) + +**Test Steps:** + +1. Create a test user (any age, doesn't matter for indefinite blocking) +2. Log in as the test user +3. Try to send a DM (even if user is old) +4. Observe what happens + +**Expected Results:** + +- DM should be blocked regardless of user age +- Warning message should appear + +**Pass/Fail Criteria:** + +- ✅ **PASS**: DM is blocked even for old users +- ❌ **FAIL**: Old users can send DMs + +--- + +### Test Suite 3: New User Link Blocking + +#### Test 3.1: New User Cannot Post HTTP URL + +**Objective:** Verify that new users cannot post HTTP links. + +**Prerequisites:** + +- Plugin enabled +- **Block New User Links**: Enabled +- **Block New User Links Time**: `24h` +- A test user created just now (new user) + +**Test Steps:** + +1. Log in as the new test user +2. Navigate to any channel +3. Post a message: `Check out http://example.com` +4. Click Send + +**Expected Results:** + +- Message should NOT appear +- Ephemeral warning: "Configuration settings limit new users from posting links." +- Link is blocked + +**Pass/Fail Criteria:** + +- ✅ **PASS**: Link is blocked and warning appears +- ❌ **FAIL**: Link is posted successfully + +--- + +#### Test 3.2: New User Cannot Post HTTPS URL + +**Objective:** Verify HTTPS links are also blocked. + +**Prerequisites:** + +- Same as Test 3.1 + +**Test Steps:** + +1. Log in as new test user +2. Post: `Visit https://example.com` + +**Expected Results:** + +- Message blocked +- Warning message appears + +**Pass/Fail Criteria:** + +- ✅ **PASS**: HTTPS link is blocked +- ❌ **FAIL**: HTTPS link bypasses filter + +--- + +#### Test 3.3: New User Cannot Post www URL + +**Objective:** Verify www-prefixed URLs are detected. + +**Prerequisites:** + +- Same as Test 3.1 + +**Test Steps:** + +1. Log in as new test user +2. Post: `See www.example.com` + +**Expected Results:** + +- Message blocked +- Warning message appears + +**Pass/Fail Criteria:** + +- ✅ **PASS**: www URL is blocked +- ❌ **FAIL**: www URL bypasses filter + +--- + +#### Test 3.4: Older User Can Post Links + +**Objective:** Verify users older than the restriction can post links. + +**Prerequisites:** + +- **Block New User Links**: Enabled +- **Block New User Links Time**: `24h` +- Test user created 25+ hours ago (modify creation date) + +**Test Steps:** + +1. Log in as older test user +2. Post: `Check out https://example.com` + +**Expected Results:** + +- Link should post successfully +- No warning message + +**Pass/Fail Criteria:** + +- ✅ **PASS**: Older user can post links +- ❌ **FAIL**: Older user is blocked + +--- + +### Test Suite 4: New User Image Blocking + +#### Test 4.1: New User Cannot Attach Image File + +**Objective:** Verify that new users cannot upload image files. + +**Prerequisites:** + +- Plugin enabled +- **Block New User Images**: Enabled +- **Block New User Images Time**: `24h` +- New test user + +**Test Steps:** + +1. Log in as new test user +2. Navigate to any channel +3. Click the attachment icon (paperclip) or drag and drop an image file +4. Select an image file (JPG, PNG, etc.) +5. Try to post the message with the image + +**Expected Results:** + +- Message should NOT be posted +- Warning: "Configuration settings limit new users from posting images." +- Image attachment is blocked + +**Pass/Fail Criteria:** + +- ✅ **PASS**: Image upload is blocked +- ❌ **FAIL**: Image is posted successfully + +--- + +#### Test 4.2: New User Cannot Post Markdown Image + +**Objective:** Verify markdown image syntax is detected. + +**Prerequisites:** + +- Same as Test 4.1 + +**Test Steps:** + +1. Log in as new test user +2. Post: `![alt text](https://example.com/image.jpg)` + +**Expected Results:** + +- Message blocked +- Warning message appears + +**Pass/Fail Criteria:** + +- ✅ **PASS**: Markdown image is blocked +- ❌ **FAIL**: Markdown image bypasses filter + +--- + +#### Test 4.3: Older User Can Post Images + +**Objective:** Verify older users can post images. + +**Prerequisites:** + +- **Block New User Images**: Enabled +- **Block New User Images Time**: `24h` +- Test user 25+ hours old + +**Test Steps:** + +1. Log in as older test user +2. Upload an image file or post markdown image + +**Expected Results:** + +- Image should post successfully +- No warning message + +**Pass/Fail Criteria:** + +- ✅ **PASS**: Older user can post images +- ❌ **FAIL**: Older user is blocked + +--- + +### Test Suite 5: Username Validation + +#### Test 5.1: Bad Username Triggers Account Cleanup + +**Objective:** Verify that users with bad usernames are automatically cleaned up. + +**Prerequisites:** + +- Plugin enabled +- **Bad Usernames**: `baduser,testbad` (add test patterns) +- User registration enabled (for this test) + +**Test Steps:** + +1. Ensure user registration is enabled (System Console → Authentication → Sign Up) +2. Navigate to registration page: `http://localhost:8065/signup_user_complete` +3. Register a new user with: + - **Username**: `baduser` (or another pattern from your list) + - **Email**: `baduser@example.com` + - **Password**: (any secure password) +4. Complete registration +5. Try to log in with the new account + +**Expected Results:** + +- Registration may complete, but account should be immediately cleaned up +- User should NOT be able to log in (account is deactivated) +- In System Console → Users, the user should show as deactivated/deleted +- Username should be changed to `sanitized-{userid}` format + +**Pass/Fail Criteria:** + +- ✅ **PASS**: Account is deactivated and username sanitized +- ❌ **FAIL**: Account remains active with original username + +--- + +#### Test 5.2: Good Username Allows Registration + +**Objective:** Verify that valid usernames allow normal registration. + +**Prerequisites:** + +- Same as Test 5.1, but use a username NOT in the bad list + +**Test Steps:** + +1. Register a new user with username: `validuser` +2. Complete registration +3. Log in with the new account + +**Expected Results:** + +- Registration should complete successfully +- User should be able to log in +- Username should remain unchanged +- Account should be active + +**Pass/Fail Criteria:** + +- ✅ **PASS**: Account is active with original username +- ❌ **FAIL**: Valid username triggers cleanup + +--- + +#### Test 5.3: Regex Pattern Matching + +**Objective:** Verify that regex patterns in username list work correctly. + +**Prerequisites:** + +- **Bad Usernames**: `test.*bad` (regex pattern) +- User registration enabled + +**Test Steps:** + +1. Register users with: + - Username: `test123bad` (should match pattern) + - Username: `testbad` (should match) + - Username: `testgood` (should NOT match) + +**Expected Results:** + +- `test123bad` and `testbad` should trigger cleanup +- `testgood` should be allowed + +**Pass/Fail Criteria:** + +- ✅ **PASS**: Regex patterns match correctly +- ❌ **FAIL**: Pattern matching fails + +--- + +### Test Suite 6: Email Domain Validation + +#### Test 6.1: Disposable Domain Blocks Registration + +**Objective:** Verify that disposable email domains are blocked. + +**Prerequisites:** + +- Plugin enabled +- **Use Built-in Bad-Domains list**: Enabled +- User registration enabled +- Find a domain from the built-in list (common disposable domains include `10minutemail.com`, `tempmail.com`, etc.) + +**Test Steps:** + +1. Try to register with email from a disposable domain: + - Email: `test@10minutemail.com` (or another disposable domain) + - Username: `validusername` + - Complete registration + +**Expected Results:** + +- Registration may complete, but account should be cleaned up immediately +- Account should be deactivated +- User cannot log in + +**Pass/Fail Criteria:** + +- ✅ **PASS**: Disposable domain triggers cleanup +- ❌ **FAIL**: Account remains active with disposable email + +--- + +#### Test 6.2: Custom Domain Pattern Blocks Registration + +**Objective:** Verify custom domain patterns work. + +**Prerequisites:** + +- **Bad Domains List**: `.*spam.*` (regex pattern) +- User registration enabled + +**Test Steps:** + +1. Register with email: `user@spamdomain.com` +2. Register with email: `user@example.com` (should NOT match) + +**Expected Results:** + +- `spamdomain.com` should trigger cleanup +- `example.com` should be allowed + +**Pass/Fail Criteria:** + +- ✅ **PASS**: Custom patterns match correctly +- ❌ **FAIL**: Pattern matching fails + +--- + +#### Test 6.3: Good Domain Allows Registration + +**Objective:** Verify legitimate domains allow registration. + +**Prerequisites:** + +- **Bad Domains List**: Only test patterns (not blocking legitimate domains) +- User registration enabled + +**Test Steps:** + +1. Register with email: `user@example.com` +2. Complete registration and log in + +**Expected Results:** + +- Registration succeeds +- Account remains active +- User can log in normally + +**Pass/Fail Criteria:** + +- ✅ **PASS**: Legitimate domain allows registration +- ❌ **FAIL**: Legitimate domain triggers cleanup + +--- + +## Troubleshooting + +### Plugin Not Working + +**Symptoms:** No filtering happening, no restrictions applied. + +**Solutions:** + +1. Verify plugin is enabled: + - System Console → Plugins → Community Toolkit → Check "Enabled" status +2. Check plugin logs: + ```bash + make dev-plugin-logs + ``` +3. Verify configuration is saved: + - Make changes in System Console → Save → Verify changes persist +4. Restart plugin: + ```bash + make dev-reset + ``` + +### User Age Calculations Not Working + +**Symptoms:** Time-based restrictions don't work correctly. + +**Solutions:** + +1. Verify user creation date in database: + ```sql + SELECT username, to_timestamp(createat/1000) as created_at + FROM users WHERE username = 'testuser'; + ``` +2. Check if timestamp was updated correctly +3. Verify the plugin configuration for time duration (e.g., `24h`, not `24 hours`) +4. Wait a few seconds after database update (plugin caches user data) + +### Can't Access Database + +**Symptoms:** `podman-compose exec postgres` command fails. + +**Solutions:** + +1. Verify Podman containers are running: + ```bash + make dev-status + ``` +2. Try accessing container directly: + ```bash + podman exec -it mattermost-plugin-community-toolkit-postgres-1 psql -U mmuser -d mattermost + ``` + (Container name may vary) + +### Configuration Not Saving + +**Symptoms:** Settings don't persist after saving. + +**Solutions:** + +1. Check for validation errors (red text in System Console) +2. Verify all required fields are filled +3. Check browser console for JavaScript errors +4. Try refreshing the page and checking again + +### Test User Cannot Log In + +**Symptoms:** After account cleanup, user cannot log in. + +**Expected Behavior:** This is correct - cleaned up accounts are soft-deleted and cannot log in. To restore: + +1. System Console → Users → Find deactivated user +2. Click on user → Restore + +--- + +## Test Execution Checklist + +### Pre-Test Setup + +- [ ] Development environment started (`make dev-up`) +- [ ] Mattermost accessible at `http://localhost:8065` +- [ ] Admin account created and logged in +- [ ] Plugin installed and enabled +- [ ] Plugin configuration accessed (System Console → Plugins → Community Toolkit) + +### Feature Testing Checklist + +#### Bad Word Filtering + +- [ ] Test 1.1: Word censoring works +- [ ] Test 1.2: Word rejection works +- [ ] Test 1.3: Bot exclusion works +- [ ] Test 1.4: Case-insensitive matching works +- [ ] Test 1.5: Custom censor character works + +#### New User DM Blocking + +- [ ] Test 2.1: New user cannot send DM +- [ ] Test 2.2: Older user can send DM +- [ ] Test 2.3: Indefinite blocking works + +#### New User Link Blocking + +- [ ] Test 3.1: HTTP links blocked for new users +- [ ] Test 3.2: HTTPS links blocked for new users +- [ ] Test 3.3: www URLs blocked for new users +- [ ] Test 3.4: Older users can post links + +#### New User Image Blocking + +- [ ] Test 4.1: Image attachments blocked for new users +- [ ] Test 4.2: Markdown images blocked for new users +- [ ] Test 4.3: Older users can post images + +#### Username Validation + +- [ ] Test 5.1: Bad username triggers cleanup +- [ ] Test 5.2: Good username allows registration +- [ ] Test 5.3: Regex patterns work correctly + +#### Email Domain Validation + +- [ ] Test 6.1: Disposable domains blocked +- [ ] Test 6.2: Custom domain patterns work +- [ ] Test 6.3: Good domains allowed + +### Post-Test Cleanup + +- [ ] Review test results +- [ ] Document any failures or issues +- [ ] Reset plugin configuration to defaults (optional) +- [ ] Clean test users (optional): `make dev-clean` for complete reset, or delete users via System Console + +--- + +## Additional Notes + +- **Timing Considerations:** After modifying user creation dates in the database, allow a few seconds for changes to take effect. The plugin caches user data, so immediate testing may show cached results. + +- **Test Isolation:** For best results, test each feature independently. Reset plugin configuration between feature tests if they conflict. + +- **Logging:** Always check plugin logs (`make dev-plugin-logs`) if tests fail unexpectedly. Logs may reveal configuration errors or plugin issues. + +- **Database Safety:** When modifying user creation dates, always verify changes with a SELECT query before testing. Be careful with UPDATE statements to avoid modifying the wrong users. + +- **Automation:** Remember that these are high-level validation tests. Comprehensive testing including edge cases, error handling, and performance should be covered by automated test suites. + +--- + +## Support and Questions + +For issues with the development environment, see `docs/PODMAN_DEVELOPMENT.md`. + +For plugin development questions, see `CLAUDE.md` and `AGENTS.md`. + +For detailed feature documentation, see `docs/NEW_USER_MODERATION.md`. diff --git a/plugin.json b/plugin.json index dd28326..e0f7a43 100644 --- a/plugin.json +++ b/plugin.json @@ -5,7 +5,7 @@ "homepage_url": "https://github.com/rocky-linux/mattermost-plugin-community-toolkit", "support_url": "https://github.com/rocky-linux/mattermost-plugin-community-toolkit/issues", "release_notes_url": "https://github.com/rocky-linux/mattermost-plugin-community-toolkit/releases/tag/v2.0.0", - "version": "2.0.6", + "version": "2.0.7", "min_server_version": "9.3.0", "server": { "executables": { @@ -41,8 +41,8 @@ "display_name": "Censor Character:", "type": "text", "help_text": "The character(s) to use to censor profanity. The letters of the censored word will be replaced with this character. Note that markdown will be interpreted. You can escape markdown character with a backslash. For using `*` you type `\\*`.", - "placeholder": "E.g.,. *", - "default": "*" + "placeholder": "E.g.,. \\*", + "default": "\\*" }, { "key": "BlockNewUserPM", @@ -54,7 +54,33 @@ "key": "BlockNewUserPMTime", "display_name": "Block New User PMs Time:", "type": "text", - "help_text": "How long to block PMs for (duration (e.g., 24h, or 12h30m))", + "help_text": "How long to block PMs for (duration (e.g., 24h, or 12h30m)). Use -1 to enable the filter indefinitely.", + "default": "24h" + }, + { + "key": "BlockNewUserLinks", + "display_name": "Block New User Links:", + "type": "bool", + "help_text": "Configure whether to block new users from posting links for some time (see BlockNewUserLinksTime)" + }, + { + "key": "BlockNewUserLinksTime", + "display_name": "Block New User Links Time:", + "type": "text", + "help_text": "How long to block link posts for (duration (e.g., 24h, or 12h30m)). Use -1 to enable the filter indefinitely.", + "default": "24h" + }, + { + "key": "BlockNewUserImages", + "display_name": "Block New User Images:", + "type": "bool", + "help_text": "Configure whether to block new users from posting images for some time (see BlockNewUserImagesTime)" + }, + { + "key": "BlockNewUserImagesTime", + "display_name": "Block New User Images Time:", + "type": "text", + "help_text": "How long to block image posts for (duration (e.g., 24h, or 12h30m)). Use -1 to enable the filter indefinitely.", "default": "24h" }, { diff --git a/podman-compose.yml b/podman-compose.yml new file mode 100644 index 0000000..c083546 --- /dev/null +++ b/podman-compose.yml @@ -0,0 +1,69 @@ +version: '3.8' + +services: + postgres: + image: docker.io/library/postgres:14 + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-mmuser} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-mmuser_password} + POSTGRES_DB: ${POSTGRES_DB:-mattermost} + volumes: + - ./podman/data/postgres:/var/lib/postgresql/data:z + networks: + - mattermost-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-mmuser}"] + interval: 10s + timeout: 5s + retries: 5 + + mattermost: + image: docker.io/mattermost/mattermost-team-edition:9.3 + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + # Database + MM_SQLSETTINGS_DRIVERNAME: postgres + MM_SQLSETTINGS_DATASOURCE: postgres://${POSTGRES_USER:-mmuser}:${POSTGRES_PASSWORD:-mmuser_password}@postgres:5432/${POSTGRES_DB:-mattermost}?sslmode=disable&connect_timeout=10 + + # Site URL + MM_SERVICESETTINGS_SITEURL: ${MM_SERVICESETTINGS_SITEURL:-http://localhost:8065} + + # Admin credentials (for first-time setup) + MM_ADMIN_USERNAME: ${MM_ADMIN_USERNAME:-admin} + MM_ADMIN_PASSWORD: ${MM_ADMIN_PASSWORD:-admin123} + MM_ADMIN_EMAIL: ${MM_ADMIN_EMAIL:-admin@example.com} + + # Enable plugin uploads + MM_PLUGINSETTINGS_ENABLEUPLOADS: "true" + + # Local mode (if needed for pluginctl socket access) + MM_LOCALSOCKETPATH: /tmp/mattermost_local.socket + MM_LOCALSOCKETENABLE: "false" + + # Additional settings + MM_LOGSETTINGS_ENABLECONSOLE: "true" + MM_LOGSETTINGS_CONSOLELEVEL: "DEBUG" + MM_SERVICESETTINGS_ENABLEDEVELOPER: "true" + volumes: + - ./podman/data/mattermost:/mattermost/data:z + - ./podman/data/mattermost/plugins:/mattermost/plugins:z + - ./podman/data/mattermost/client/plugins:/mattermost/client/plugins:z + - ./podman/config:/mattermost/config:z + ports: + - "8065:8065" + networks: + - mattermost-network + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8065/api/v4/system/ping || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + +networks: + mattermost-network: + driver: bridge diff --git a/podman/env.example b/podman/env.example new file mode 100644 index 0000000..443949d --- /dev/null +++ b/podman/env.example @@ -0,0 +1,25 @@ +# PostgreSQL Configuration +POSTGRES_USER=mmuser +POSTGRES_PASSWORD=mmuser_password +POSTGRES_DB=mattermost + +# Mattermost Configuration +MM_SERVICESETTINGS_SITEURL=http://localhost:8065 + +# Admin User (for Mattermost first-time setup) +# These are used by Mattermost during initial configuration +MM_ADMIN_USERNAME=admin +MM_ADMIN_PASSWORD=admin123 +MM_ADMIN_EMAIL=admin@example.com + +# Plugin Deployment Credentials (for pluginctl) +# Set these AFTER creating your admin user to enable API-based plugin deployment +# Option 1: Use an admin token (recommended) +# Generate from: System Console > Integrations > Bot Accounts > Personal Access Tokens +MM_ADMIN_TOKEN= + +# Option 2: Use username/password (alternative to token) +# If using token above, these can be left empty +# MM_ADMIN_USERNAME=admin +# MM_ADMIN_PASSWORD=admin123 + diff --git a/scripts/dev-check-container.sh b/scripts/dev-check-container.sh new file mode 100755 index 0000000..b69bbba --- /dev/null +++ b/scripts/dev-check-container.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# +# dev-check-container.sh - Verify Mattermost development container status +# +# PURPOSE: +# This script ensures the Mattermost development container is running before +# other scripts attempt to interact with it. It also loads environment variables +# from the podman/.env file to make credentials and configuration available to +# calling scripts. +# +# USAGE: +# This script is typically sourced by other development scripts: +# source scripts/dev-check-container.sh +# +# ENVIRONMENT: +# PODMAN_COMPOSE_FILE - Path to podman-compose file (default: podman-compose.yml) +# +# EXIT CODES: +# 0 - Container is running and ready +# 1 - Container is not running or dependencies missing +# +# NOTES: +# - This script changes to PROJECT_ROOT directory +# - Environment variables from podman/.env are exported to calling shell +# - Use 'set -a' pattern to automatically export variables when sourcing .env +# + +set -euo pipefail + +# Determine script and project directories +# Only set as readonly if not already defined (allows sourcing by other scripts) +if [[ -z "${SCRIPT_DIR:-}" ]]; then + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + readonly SCRIPT_DIR +fi + +if [[ -z "${PROJECT_ROOT:-}" ]]; then + PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + readonly PROJECT_ROOT +fi + +if [[ -z "${PODMAN_COMPOSE_FILE:-}" ]]; then + PODMAN_COMPOSE_FILE="podman-compose.yml" + readonly PODMAN_COMPOSE_FILE +fi + +# Check for required tools +if ! command -v podman-compose &>/dev/null; then + echo "Error: podman-compose is not installed or not in PATH" >&2 + echo "Install it with: pip install podman-compose" >&2 + exit 1 +fi + +# Change to project root for consistent behavior +cd "${PROJECT_ROOT}" + +# Verify Mattermost container is running +# We check for "Up" or "starting" status in the podman-compose output +if ! podman-compose -f "${PODMAN_COMPOSE_FILE}" ps | grep -qE "mattermost.*(Up|starting)"; then + echo "Error: Mattermost container is not running" >&2 + echo "Start it with: make dev-up" >&2 + exit 1 +fi + +# Load environment variables from podman/.env if present +# Using 'set -a' causes all variables to be exported automatically +readonly ENV_FILE="podman/.env" +if [[ -f "${ENV_FILE}" ]]; then + set -a + # shellcheck source=/dev/null + source "${ENV_FILE}" + set +a +fi + diff --git a/scripts/dev-create-admin.sh b/scripts/dev-create-admin.sh new file mode 100755 index 0000000..212df68 --- /dev/null +++ b/scripts/dev-create-admin.sh @@ -0,0 +1,364 @@ +#!/bin/bash +# +# dev-create-admin.sh - Create admin user and default team in Mattermost +# +# PURPOSE: +# Automates the creation of an admin user account and default team in a local +# Mattermost development instance. This saves manual setup time and ensures +# a consistent development environment. +# +# USAGE: +# ./scripts/dev-create-admin.sh +# make dev-create-admin (recommended) +# +# ENVIRONMENT: +# MM_ADMIN_USERNAME - Admin username (default: admin) +# MM_ADMIN_PASSWORD - Admin password (default: admin123) +# MM_ADMIN_EMAIL - Admin email (default: admin@example.com) +# +# EXIT CODES: +# 0 - Admin account and team created/verified successfully +# 1 - Creation failed (container not running, API errors, etc.) +# +# NOTES: +# - Idempotent: safe to run multiple times, detects existing user/team +# - Creates default team "mpct" and adds admin as team admin +# - Uses Mattermost REST API v4 +# - First user can be created without authentication (Mattermost feature) +# - Subsequent operations require authentication (token or basic auth) +# + +set -euo pipefail + +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# Source the helper script to verify container is running and load environment +# shellcheck source=/dev/null +source "${SCRIPT_DIR}/dev-check-container.sh" + +cd "${PROJECT_ROOT}" + +# Check for required tools +for tool in curl grep awk sed; do + if ! command -v "${tool}" &>/dev/null; then + echo "Error: Required tool '${tool}' is not installed" >&2 + exit 1 + fi +done + +# Configuration with defaults from environment +readonly ADMIN_USERNAME="${MM_ADMIN_USERNAME:-admin}" +readonly ADMIN_PASSWORD="${MM_ADMIN_PASSWORD:-admin123}" +readonly ADMIN_EMAIL="${MM_ADMIN_EMAIL:-admin@example.com}" +readonly MATTERMOST_URL="http://localhost:8065" +readonly TEAM_NAME="mpct" +readonly TEAM_DISPLAY_NAME="MPCT" + +# Authentication token (populated after login) +AUTH_TOKEN="" + +# Cleanup function to remove temporary files +cleanup() { + if [[ -n "${COOKIE_JAR:-}" ]] && [[ -f "${COOKIE_JAR}" ]]; then + rm -f "${COOKIE_JAR}" + fi +} + +# Register cleanup on script exit +trap cleanup EXIT + +echo "Setting up admin account: ${ADMIN_USERNAME} / ${ADMIN_EMAIL}" +echo "" + +#------------------------------------------------------------------------------ +# Helper Functions +#------------------------------------------------------------------------------ + +# Extract ID from JSON response +# Mattermost API returns JSON like: {"id":"abc123...", ...} +# We extract the first "id" field value using basic text tools +# +# Args: +# $1 - JSON string to parse +# Returns: +# ID value (or empty string if not found) +extract_id() { + local json="$1" + echo "${json}" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4 +} + +# Validate Mattermost ID format +# Mattermost uses 26-character alphanumeric IDs +# +# Args: +# $1 - ID string to validate +# Returns: +# 0 if valid, 1 if invalid +is_valid_id() { + local id="$1" + [[ -n "${id}" ]] && echo "${id}" | grep -qE '^[a-zA-Z0-9]{26}$' +} + +# Authenticate and retrieve API token +# Tries multiple methods to extract the auth token from login response +# +# Returns: +# Auth token string (or empty if login failed) +get_auth_token() { + # Create temporary file for cookies + COOKIE_JAR="$(mktemp)" + + # Attempt login and capture full response including headers + local login_response + login_response="$(curl -s -i -c "${COOKIE_JAR}" -X POST \ + "${MATTERMOST_URL}/api/v4/users/login" \ + -H "Content-Type: application/json" \ + -d "{\"login_id\":\"${ADMIN_USERNAME}\",\"password\":\"${ADMIN_PASSWORD}\"}" 2>&1 || true)" + + # Method 1: Extract from Token header (preferred) + local token + token="$(echo "${login_response}" | grep -i "^Token:" | sed 's/^Token: //' | tr -d '\r\n')" + + # Method 2: Extract from Set-Cookie header + if [[ -z "${token}" ]]; then + token="$(echo "${login_response}" | grep -i "^Set-Cookie:" | \ + grep -o "MMAUTHTOKEN=[^;]*" | sed 's/MMAUTHTOKEN=//' | head -1)" + fi + + # Method 3: Read from cookie jar file + if [[ -z "${token}" ]] && [[ -f "${COOKIE_JAR}" ]]; then + token="$(grep -i "MMAUTHTOKEN" "${COOKIE_JAR}" | awk '{print $NF}' | head -1)" + fi + + echo "${token}" +} + +# Make authenticated API call to Mattermost +# Supports both token and basic authentication +# +# Args: +# $1 - HTTP method (GET, POST, PUT, etc.) +# $2 - Full URL to call +# $3 - Optional JSON data for request body +# Returns: +# API response body +api_call() { + local method="${1:-GET}" + local url="$2" + local data="${3:-}" + + # Build curl command based on auth method and whether we have data + if [[ -n "${AUTH_TOKEN}" ]]; then + # Use token authentication (preferred) + if [[ -n "${data}" ]]; then + curl -s -X "${method}" \ + -H "Authorization: Bearer ${AUTH_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "${data}" \ + "${url}" + else + curl -s -X "${method}" \ + -H "Authorization: Bearer ${AUTH_TOKEN}" \ + "${url}" + fi + else + # Fall back to basic authentication + if [[ -n "${data}" ]]; then + curl -s -X "${method}" \ + -u "${ADMIN_USERNAME}:${ADMIN_PASSWORD}" \ + -H "Content-Type: application/json" \ + -d "${data}" \ + "${url}" + else + curl -s -X "${method}" \ + -u "${ADMIN_USERNAME}:${ADMIN_PASSWORD}" \ + "${url}" + fi + fi +} + +#------------------------------------------------------------------------------ +# Main Script Logic +#------------------------------------------------------------------------------ + +# Step 1: Create admin user +# First user in Mattermost can be created without authentication +echo "Creating admin user..." +user_create_response="$(curl -s -X POST "${MATTERMOST_URL}/api/v4/users" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"${ADMIN_EMAIL}\",\"username\":\"${ADMIN_USERNAME}\",\"password\":\"${ADMIN_PASSWORD}\",\"allow_marketing\":false}" \ + 2>&1 || true)" + +# Determine if creation succeeded, user already exists, or failed +USER_ID="" +if echo "${user_create_response}" | grep -q "\"username\":\"${ADMIN_USERNAME}\""; then + echo "✓ Admin account created successfully" + USER_ID="$(extract_id "${user_create_response}")" + # Allow time for user to be fully processed in the system + sleep 2 +elif echo "${user_create_response}" | grep -qiE "already exists|already registered|unique constraint"; then + echo "✓ Admin account already exists" + # Retrieve user ID via API lookup + user_response="$(api_call GET "${MATTERMOST_URL}/api/v4/users/username/${ADMIN_USERNAME}")" + USER_ID="$(extract_id "${user_response}")" +else + echo "Error: Failed to create admin account" >&2 + echo "Response: ${user_create_response}" >&2 + exit 1 +fi + +# Validate we have a user ID before proceeding +if [[ -z "${USER_ID}" ]]; then + echo "Error: Could not determine user ID" >&2 + exit 1 +fi + +# Step 2: Authenticate for subsequent operations +echo "Authenticating..." +AUTH_TOKEN="$(get_auth_token)" + +# Retry authentication if it fails (API may need a moment) +readonly MAX_RETRIES=3 +retry_count=0 +while [[ -z "${AUTH_TOKEN}" ]] && (( retry_count < MAX_RETRIES )); do + retry_count=$((retry_count + 1)) + if (( retry_count < MAX_RETRIES )); then + echo " Retry ${retry_count}/${MAX_RETRIES}..." + sleep 2 + AUTH_TOKEN="$(get_auth_token)" + fi +done + +# Warn if authentication failed, but continue with basic auth fallback +if [[ -z "${AUTH_TOKEN}" ]]; then + echo "Warning: Failed to authenticate after ${MAX_RETRIES} attempts" >&2 + echo " Will attempt basic authentication for remaining operations" >&2 +else + echo "✓ Authentication successful" +fi + +echo "" +echo "Setting up default team..." + +# Step 3: Check if team exists or create it +team_response="$(api_call GET "${MATTERMOST_URL}/api/v4/teams/name/${TEAM_NAME}" 2>&1 || true)" + +TEAM_ID="" +# Validate response contains team name (indicates successful lookup) +if echo "${team_response}" | grep -q "\"name\":\"${TEAM_NAME}\""; then + TEAM_ID="$(extract_id "${team_response}")" + if is_valid_id "${TEAM_ID}"; then + echo "✓ Team '${TEAM_NAME}' already exists" + else + # Invalid ID format, treat as not found + TEAM_ID="" + fi +fi + +# Create team if it doesn't exist +if [[ -z "${TEAM_ID}" ]]; then + echo "Creating team '${TEAM_NAME}'..." + team_create_response="$(api_call POST "${MATTERMOST_URL}/api/v4/teams" \ + "{\"name\":\"${TEAM_NAME}\",\"display_name\":\"${TEAM_DISPLAY_NAME}\",\"type\":\"O\"}")" + + if echo "${team_create_response}" | grep -q "\"name\":\"${TEAM_NAME}\""; then + # Successfully created - extract and validate ID + TEAM_ID="$(extract_id "${team_create_response}")" + if is_valid_id "${TEAM_ID}"; then + echo "✓ Team '${TEAM_NAME}' created successfully" + else + echo "Error: Failed to extract valid team ID from response" >&2 + echo "Response: ${team_create_response}" >&2 + exit 1 + fi + elif echo "${team_create_response}" | grep -qiE "already exists|already registered"; then + # Race condition: team was created between our check and creation attempt + echo "✓ Team '${TEAM_NAME}' already exists" + team_response="$(api_call GET "${MATTERMOST_URL}/api/v4/teams/name/${TEAM_NAME}")" + if echo "${team_response}" | grep -q "\"name\":\"${TEAM_NAME}\""; then + TEAM_ID="$(extract_id "${team_response}")" + if [[ -z "${TEAM_ID}" ]] || ! is_valid_id "${TEAM_ID}"; then + echo "Error: Failed to extract valid team ID" >&2 + echo "Response: ${team_response}" >&2 + exit 1 + fi + else + echo "Error: Failed to retrieve team after creation" >&2 + echo "Response: ${team_response}" >&2 + exit 1 + fi + else + echo "Error: Failed to create team" >&2 + echo "Response: ${team_create_response}" >&2 + echo "Note: Admin user may need system admin privileges to create teams" >&2 + exit 1 + fi +fi + +# Final validation that we have a team ID +if [[ -z "${TEAM_ID}" ]]; then + echo "Error: Could not determine team ID" >&2 + exit 1 +fi + +# Step 4: Add user to team (if not already a member) +member_check_response="$(api_call GET "${MATTERMOST_URL}/api/v4/teams/${TEAM_ID}/members/${USER_ID}" 2>&1 || true)" + +if echo "${member_check_response}" | grep -q '"user_id"'; then + echo "✓ Admin user is already a member of team '${TEAM_NAME}'" +else + echo "Adding admin user to team '${TEAM_NAME}'..." + member_response="$(api_call POST "${MATTERMOST_URL}/api/v4/teams/${TEAM_ID}/members" \ + "{\"team_id\":\"${TEAM_ID}\",\"user_id\":\"${USER_ID}\"}")" + + if echo "${member_response}" | grep -q '"user_id"'; then + echo "✓ Admin user added to team '${TEAM_NAME}'" + elif echo "${member_response}" | grep -qiE "already exists|already a member"; then + # Race condition: user was added between check and addition + echo "✓ Admin user is already a member of team '${TEAM_NAME}'" + else + echo "Error: Failed to add admin user to team" >&2 + echo "Response: ${member_response}" >&2 + echo "Note: Admin user may need system admin privileges to add members" >&2 + exit 1 + fi +fi + +# Step 5: Set user as team admin +echo "Setting admin user as team admin..." +role_update_response="$(api_call PUT \ + "${MATTERMOST_URL}/api/v4/teams/${TEAM_ID}/members/${USER_ID}/roles" \ + '{"roles":"team_admin team_user"}')" + +# API returns {"status":"OK"} on success (case may vary) +if echo "${role_update_response}" | grep -qiE '"status":"ok"'; then + echo "✓ Admin user set as team admin" +elif echo "${role_update_response}" | grep -qiE "error|failed"; then + # Not fatal - user can still use Mattermost, just won't have team admin privileges + echo "Warning: Failed to set team admin role" >&2 + echo "Response: ${role_update_response}" >&2 +else + # Other status values might indicate success (e.g., "success") + if echo "${role_update_response}" | grep -qi '"status"'; then + echo "✓ Admin user set as team admin" + else + echo "Warning: Unexpected response when setting team admin role" >&2 + echo "Response: ${role_update_response}" >&2 + fi +fi + +echo "" +echo "======================================================================" +echo "✓ Admin account and team setup complete!" +echo "======================================================================" +echo "" +echo "Login credentials:" +echo " Username: ${ADMIN_USERNAME}" +echo " Email: ${ADMIN_EMAIL}" +echo " Password: ${ADMIN_PASSWORD}" +echo "" +echo "Team: ${TEAM_DISPLAY_NAME} (${TEAM_NAME})" +echo "" +echo "Login at: ${MATTERMOST_URL}/login" +echo "" diff --git a/scripts/dev-deploy.sh b/scripts/dev-deploy.sh new file mode 100755 index 0000000..a22218d --- /dev/null +++ b/scripts/dev-deploy.sh @@ -0,0 +1,101 @@ +#!/bin/bash +# +# dev-deploy.sh - Deploy plugin to local Mattermost development environment +# +# PURPOSE: +# Builds and deploys the Mattermost plugin to a running Podman development +# stack. This script locates the latest plugin bundle and uses pluginctl +# to install it on the local Mattermost instance. +# +# USAGE: +# ./scripts/dev-deploy.sh +# make deploy (recommended - ensures build happens first) +# +# ENVIRONMENT: +# PLUGIN_ID - Plugin identifier (default: mattermost-community-toolkit) +# BUNDLE_NAME - Path to plugin bundle, or auto-detect latest in dist/ +# MM_ADMIN_TOKEN - Admin auth token (preferred) +# MM_ADMIN_USERNAME - Admin username (fallback auth method) +# MM_ADMIN_PASSWORD - Admin password (used with username) +# +# EXIT CODES: +# 0 - Plugin deployed successfully +# 1 - Deployment failed (missing dependencies, container not running, etc.) +# +# NOTES: +# - Requires Mattermost container to be running (checked via dev-check-container.sh) +# - Authentication credentials should be set in podman/.env +# - After deployment, plugin must be enabled via 'make dev-enable' or System Console +# + +set -euo pipefail + +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# Source the helper script to verify container is running and load environment +# shellcheck source=/dev/null +source "${SCRIPT_DIR}/dev-check-container.sh" + +cd "${PROJECT_ROOT}" + +# Check for required tools +if [[ ! -x "./build/bin/pluginctl" ]]; then + echo "Error: pluginctl not found at ./build/bin/pluginctl" >&2 + echo "Build the project first with: make all" >&2 + exit 1 +fi + +echo "Deploying plugin to development environment..." + +# Validate deployment credentials are configured +if [[ -z "${MM_ADMIN_TOKEN:-}" ]] && [[ -z "${MM_ADMIN_USERNAME:-}" ]]; then + echo "Warning: No authentication credentials configured" >&2 + echo " Set MM_ADMIN_TOKEN or MM_ADMIN_USERNAME/MM_ADMIN_PASSWORD" >&2 + echo " in podman/.env (see podman/env.example for template)" >&2 + echo "" >&2 + echo "Deployment may fail without proper authentication." >&2 + # Continue anyway - pluginctl will fail with a clear error if auth is required +fi + +# Get plugin configuration from environment or use defaults +readonly PLUGIN_ID="${PLUGIN_ID:-mattermost-community-toolkit}" +BUNDLE_NAME="${BUNDLE_NAME:-}" + +# Auto-detect latest bundle if not explicitly specified +if [[ -z "${BUNDLE_NAME}" ]]; then + # Find most recently modified .tar.gz file in dist/ + BUNDLE_NAME="$(find dist -maxdepth 1 -name "*.tar.gz" -type f -printf "%T@ %p\n" 2>/dev/null | \ + sort -rn | head -1 | cut -d' ' -f2-)" + + if [[ -z "${BUNDLE_NAME}" ]]; then + echo "Error: No plugin bundle found in dist/" >&2 + echo "Build the plugin first with: make dist" >&2 + exit 1 + fi + + echo "Using bundle: ${BUNDLE_NAME}" +fi + +# Verify bundle file exists and is readable +if [[ ! -f "${BUNDLE_NAME}" ]]; then + echo "Error: Bundle file not found: ${BUNDLE_NAME}" >&2 + exit 1 +fi + +if [[ ! -r "${BUNDLE_NAME}" ]]; then + echo "Error: Bundle file not readable: ${BUNDLE_NAME}" >&2 + exit 1 +fi + +# Deploy the plugin using pluginctl +# MM_SERVICESETTINGS_SITEURL tells pluginctl where to find the Mattermost API +MM_SERVICESETTINGS_SITEURL="http://localhost:8065" \ + ./build/bin/pluginctl deploy "${PLUGIN_ID}" "${BUNDLE_NAME}" + +echo "" +echo "Plugin deployed successfully!" +echo "Next steps:" +echo " - Enable: make dev-enable" +echo " - Or enable via System Console at http://localhost:8065" + diff --git a/server/configuration.go b/server/configuration.go index 71e2bb2..db6c4ef 100644 --- a/server/configuration.go +++ b/server/configuration.go @@ -8,6 +8,7 @@ import ( "regexp" "sort" "strings" + "time" "github.com/pkg/errors" ) @@ -24,16 +25,21 @@ import ( // If you add non-reference types to your configuration struct, be sure to rewrite Clone as a deep // copy appropriate for your types. type configuration struct { - BadDomainsList string - BadUsernamesList string - BuiltinBadDomains bool - BadWordsList string - BlockNewUserPM bool - BlockNewUserPMTime string - CensorCharacter string - ExcludeBots bool - RejectPosts bool - WarningMessage string `json:"WarningMessage"` + BadDomainsList string + BadUsernamesList string + BuiltinBadDomains bool + BadWordsList string + BlockNewUserPM bool + BlockNewUserPMTime string + BlockNewUserLinks bool + BlockNewUserLinksTime string + BlockNewUserImages bool + BlockNewUserImagesTime string + CensorCharacter string + ExcludeBots bool + RejectPosts bool + WarningMessage string `json:"WarningMessage"` + AdminUsername string `json:"AdminUsername"` } //go:embed bad-domains.txt @@ -108,17 +114,70 @@ func (p *Plugin) OnConfigurationChange() error { return errors.Wrap(err, "failed to load plugin configuration") } + // Validate duration configurations + if err := validateDurationConfig(configuration.BlockNewUserPMTime, "BlockNewUserPMTime"); err != nil { + return err + } + if err := validateDurationConfig(configuration.BlockNewUserLinksTime, "BlockNewUserLinksTime"); err != nil { + return err + } + if err := validateDurationConfig(configuration.BlockNewUserImagesTime, "BlockNewUserImagesTime"); err != nil { + return err + } + p.setConfiguration(configuration) if p.cache == nil { p.cache = NewLRUCache(50) } - p.badWordsRegex = splitWordListToRegex(configuration.BadWordsList) - p.badDomainsRegex = splitWordListToRegex(configuration.BadDomainsList) - p.badUsernamesRegex = splitWordListToRegex(configuration.BadUsernamesList, `(?mi)(%s)`) + // Compile regex patterns from word lists + badWordsRegex, err := splitWordListToRegex(configuration.BadWordsList) + if err != nil { + p.API.LogError(fmt.Sprintf("Invalid regex in BadWordsList: %v", err)) + return errors.Wrap(err, "failed to compile BadWordsList regex") + } + p.badWordsRegex = badWordsRegex + + // Use template without word boundaries for domains to support regex patterns + badDomainsRegex, err := splitWordListToRegex(configuration.BadDomainsList, `(?mi)(%s)`) + if err != nil { + p.API.LogError(fmt.Sprintf("Invalid regex in BadDomainsList: %v", err)) + return errors.Wrap(err, "failed to compile BadDomainsList regex") + } + p.badDomainsRegex = badDomainsRegex + + badUsernamesRegex, err := splitWordListToRegex(configuration.BadUsernamesList, `(?mi)(%s)`) + if err != nil { + p.API.LogError(fmt.Sprintf("Invalid regex in BadUsernamesList: %v", err)) + return errors.Wrap(err, "failed to compile BadUsernamesList regex") + } + p.badUsernamesRegex = badUsernamesRegex + + if err := p.setupBadDomainList(); err != nil { + return errors.Wrap(err, "failed to setup bad domain list") + } + + return nil +} + +// validateDurationConfig validates that a duration string is either "-1" (indefinite) or a valid Go duration +func validateDurationConfig(duration string, fieldName string) error { + // Empty duration is valid (feature disabled) + if duration == "" { + return nil + } + + // "-1" means indefinite blocking, which is valid + if duration == "-1" { + return nil + } - p.setupBadDomainList() + // Try to parse as a valid Go duration + _, err := time.ParseDuration(duration) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("invalid duration format for %s: %s", fieldName, duration)) + } return nil } @@ -132,10 +191,10 @@ func jsonArrayToStringSlice(jsonArray string) (*[]string, error) { return &result, nil } -func splitWordListToRegex(wordList string, regexTemplateOptional ...string) *regexp.Regexp { +func splitWordListToRegex(wordList string, regexTemplateOptional ...string) (*regexp.Regexp, error) { // If there's no list of words, don't make them into a regex if len(wordList) < 1 { - return nil + return nil, nil } // Choose the regex template @@ -148,14 +207,19 @@ func splitWordListToRegex(wordList string, regexTemplateOptional ...string) *reg regexString := wordListToRegex(wordList, regexTemplate) regex, err := regexp.Compile(regexString) if err != nil { - panic(fmt.Errorf("unable to split wordlist to regex: %v", err)) + return nil, errors.Wrap(err, "unable to compile regex from wordlist") } - return regex + return regex, nil } func wordListToRegex(wordList string, regexTemplate string) string { split := strings.Split(wordList, ",") + // Trim whitespace from each item + for i := range split { + split[i] = strings.TrimSpace(split[i]) + } + // Sorting by length so that longer words come first sort.Slice(split, func(i, j int) bool { return len(split[i]) > len(split[j]) }) diff --git a/server/configuration_test.go b/server/configuration_test.go index a47b032..3d68ef6 100644 --- a/server/configuration_test.go +++ b/server/configuration_test.go @@ -28,13 +28,13 @@ func TestWordListToRegex(t *testing.T) { }, } - t.Run("Build In double Regex", func(t *testing.T) { + t.Run("Build regex with duplicate words using default template", func(t *testing.T) { regexStr := wordListToRegex(p2.getConfiguration().BadWordsList, defaultRegexTemplate) assert.Equal(t, regexStr, `(?mi)\b(abc def|abc)\b`) }) - t.Run("Build In double Regex", func(t *testing.T) { + t.Run("Build regex with duplicate words using custom template", func(t *testing.T) { regexStr := wordListToRegex(p2.getConfiguration().BadWordsList, `(?mi)^(%s)$`) assert.Equal(t, regexStr, `(?mi)^(abc def|abc)$`) @@ -65,7 +65,7 @@ func TestOnConfigurationChange(t *testing.T) { cfg.BadWordsList = "test,word" cfg.BadDomainsList = "bad.com" cfg.BadUsernamesList = "baduser" - cfg.CensorCharacter = "*" + cfg.CensorCharacter = "\\*" cfg.RejectPosts = true cfg.ExcludeBots = true cfg.BlockNewUserPM = true @@ -93,7 +93,7 @@ func TestOnConfigurationChange(t *testing.T) { assert.Equal(t, "test,word", cfg.BadWordsList) assert.Equal(t, "bad.com", cfg.BadDomainsList) assert.Equal(t, "baduser", cfg.BadUsernamesList) - assert.Equal(t, "*", cfg.CensorCharacter) + assert.Equal(t, "\\*", cfg.CensorCharacter) assert.True(t, cfg.RejectPosts) assert.True(t, cfg.ExcludeBots) assert.True(t, cfg.BlockNewUserPM) @@ -274,6 +274,89 @@ func TestOnConfigurationChange(t *testing.T) { assert.Equal(t, "original", originalConfig.BadWordsList) assert.Equal(t, "*", originalConfig.CensorCharacter) }) + + t.Run("returns error for invalid duration config", func(t *testing.T) { + p := Plugin{} + + mockAPI := &MockConfigAPI{ + LoadPluginConfigurationFunc: func(dest interface{}) error { + if cfg, ok := dest.(*configuration); ok { + cfg.BlockNewUserPMTime = "invalid-duration" + } + return nil + }, + } + p.SetAPI(mockAPI) + + err := p.OnConfigurationChange() + + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid duration format") + }) +} + +func TestJsonArrayToStringSlice(t *testing.T) { + t.Run("parses valid JSON array", func(t *testing.T) { + jsonArray := `["domain1.com", "domain2.com", "domain3.com"]` + result, err := jsonArrayToStringSlice(jsonArray) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, 3, len(*result)) + assert.Equal(t, "domain1.com", (*result)[0]) + assert.Equal(t, "domain2.com", (*result)[1]) + assert.Equal(t, "domain3.com", (*result)[2]) + }) + + t.Run("returns error for invalid JSON", func(t *testing.T) { + invalidJSON := `["domain1.com", "domain2.com"` // Missing closing bracket + result, err := jsonArrayToStringSlice(invalidJSON) + + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "unexpected") + }) + + t.Run("returns error for non-array JSON", func(t *testing.T) { + nonArrayJSON := `{"domains": ["domain1.com"]}` // Object instead of array + result, err := jsonArrayToStringSlice(nonArrayJSON) + + assert.Error(t, err) + assert.Nil(t, result) + }) + + t.Run("parses empty array", func(t *testing.T) { + emptyArray := `[]` + result, err := jsonArrayToStringSlice(emptyArray) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, 0, len(*result)) + }) + + t.Run("returns error for array with non-string values", func(t *testing.T) { + // This should fail because JSON contains non-string values + mixedArray := `["domain1.com", 123, "domain2.com"]` + result, err := jsonArrayToStringSlice(mixedArray) + + // JSON unmarshal will fail when trying to unmarshal number into string slice + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "cannot unmarshal") + }) +} + +func TestSetupBadDomainList(t *testing.T) { + t.Run("successfully sets up bad domain list from embedded file", func(t *testing.T) { + p := Plugin{} + + err := p.setupBadDomainList() + + // This should succeed because the embedded file is valid JSON + assert.NoError(t, err) + assert.NotNil(t, p.badDomainsList) + assert.Greater(t, len(*p.badDomainsList), 0) + }) } func TestSetConfiguration(t *testing.T) { @@ -375,8 +458,9 @@ func TestClone(t *testing.T) { func TestSplitWordListToRegex(t *testing.T) { t.Run("creates regex from word list", func(t *testing.T) { - regex := splitWordListToRegex("word1,word2,word3") + regex, err := splitWordListToRegex("word1,word2,word3") + assert.NoError(t, err) assert.NotNil(t, regex) assert.True(t, regex.MatchString("contains word1 here")) assert.True(t, regex.MatchString("word2")) @@ -385,14 +469,16 @@ func TestSplitWordListToRegex(t *testing.T) { }) t.Run("returns nil for empty list", func(t *testing.T) { - regex := splitWordListToRegex("") + regex, err := splitWordListToRegex("") + assert.NoError(t, err) assert.Nil(t, regex) }) t.Run("uses custom template", func(t *testing.T) { - regex := splitWordListToRegex("test", `^(%s)$`) + regex, err := splitWordListToRegex("test", `^(%s)$`) + assert.NoError(t, err) assert.NotNil(t, regex) assert.True(t, regex.MatchString("test")) assert.False(t, regex.MatchString("test123")) @@ -400,12 +486,22 @@ func TestSplitWordListToRegex(t *testing.T) { }) t.Run("sorts by length descending", func(t *testing.T) { - regex := splitWordListToRegex("a,abc,ab") + regex, err := splitWordListToRegex("a,abc,ab") + assert.NoError(t, err) // The regex should be ordered as: abc|ab|a assert.NotNil(t, regex) // This ensures longest match first match := regex.FindString("abc") assert.Equal(t, "abc", match) }) + + t.Run("returns error for invalid regex pattern", func(t *testing.T) { + // Use a malformed regex pattern + regex, err := splitWordListToRegex("test", `(?P 24h duration, should be allowed + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isTooNew, errorMsg, err := p.isUserTooNew(tt.user, tt.blockDuration, tt.contentType) + + if tt.expectError { + assert.Error(t, err, "Expected error for test: %s", tt.name) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains, "Error should contain expected text") + } + } else { + assert.NoError(t, err, "Expected no error for test: %s", tt.name) + assert.Equal(t, tt.expectTooNew, isTooNew, "isUserTooNew() should return %v for %s", tt.expectTooNew, tt.name) + + if isTooNew { + assert.NotEmpty(t, errorMsg, "Error message should not be empty when user is too new") + assert.Contains(t, errorMsg, tt.contentType, "Error message should mention content type") + } else { + assert.Empty(t, errorMsg, "Error message should be empty when user is allowed") + } + } + }) + } +} diff --git a/server/plugin.go b/server/plugin.go index 9cc42c1..28c548d 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -48,49 +48,58 @@ func (p *Plugin) FilterPost(post *model.Post) (*model.Post, string) { return post, "" } + // Dev Note: There is an order of operation sensitivity here - please + // note comments on checks below. + + // Check if user should be blocked (bad username or email domain) + // This prevents posts during the UserHasBeenCreated cleanup window + if shouldBlock := p.shouldBlockUserPost(post.UserId); shouldBlock { + p.sendUserEphemeralMessageForPost(post, "Your account has been flagged for moderation. Please contact an administrator.") + return nil, "User account flagged for moderation" + } + if configuration.BlockNewUserPM && p.isDirectMessage(post.ChannelId) { return p.FilterDirectMessage(configuration, post) } + // Check images before links because image URLs match both checks, + // and images should take priority (more specific content type) + if configuration.BlockNewUserImages && p.containsImages(post) { + return p.FilterNewUserImages(configuration, post) + } + + if configuration.BlockNewUserLinks && p.containsLinks(post) { + return p.FilterNewUserLinks(configuration, post) + } + return p.FilterPostBadWords(configuration, post) } func (p *Plugin) GetUserByID(userID string) (*model.User, error) { - if user, found := p.cache.Get(userID); found { - return user, nil + if p.cache != nil { + if user, found := p.cache.Get(userID); found { + return user, nil + } } user, err := p.API.GetUser(userID) if err != nil { return &model.User{}, fmt.Errorf("failed to find user with id %v", userID) } - cacheUser := *user - p.cache.Put(user.Id, &cacheUser) - - return &cacheUser, nil + if p.cache != nil { + cacheUser := *user + p.cache.Put(user.Id, &cacheUser) + return &cacheUser, nil + } + return user, nil } func (p *Plugin) FilterDirectMessage(configuration *configuration, post *model.Post) (*model.Post, string) { - user, err := p.GetUserByID(post.UserId) - if err != nil { - p.sendUserEphemeralMessageForPost(post, "Something went wrong when sending your message. Contact an administrator.") - return nil, "Failed to get user" - } - - userCreateSeconds := user.CreateAt / 1000 - createdAt := time.Unix(userCreateSeconds, 0) - blockDuration := configuration.BlockNewUserPMTime - duration, parseErr := time.ParseDuration(blockDuration) - - if parseErr != nil { - p.sendUserEphemeralMessageForPost(post, "Something went wrong when sending your message. Contact an administrator.") - return nil, "failed to parse duration" - } - - if time.Since(createdAt) < duration { - p.sendUserEphemeralMessageForPost(post, "Configuration settings limit new users from sending private messages.") - return nil, fmt.Sprintf("New user not allowed to send DM for %s.", duration) - } - return post, "" + return p.filterNewUserContent( + post, + "direct messages", + configuration.BlockNewUserPMTime, + "Configuration settings limit new users from sending private messages.", + ) } func (p *Plugin) FilterPostBadWords(configuration *configuration, post *model.Post) (*model.Post, string) { @@ -123,6 +132,30 @@ func (p *Plugin) FilterPostBadWords(configuration *configuration, post *model.Po return post, "" } +// Plugin Callback: UserWillLogIn +// Executed before a user authenticates. Returns an error message to prevent login. +func (p *Plugin) UserWillLogIn(_ *plugin.Context, user *model.User) string { + // Check if user is soft-deleted + if user.DeleteAt > 0 { + p.API.LogWarn(fmt.Sprintf("Blocking login attempt for soft-deleted user: %s", user.Id)) + return "Your account has been deactivated due to policy violations. Please contact an administrator." + } + + // Check if user has bad username + if err := p.checkBadUsername(user); err != nil { + p.API.LogWarn(fmt.Sprintf("Blocking login for user with bad username: %s (username: %s)", user.Id, user.Username)) + return "Your account violates community guidelines. Please contact an administrator." + } + + // Check if user has bad email domain + if err := p.checkBadEmail(user); err != nil { + p.API.LogWarn(fmt.Sprintf("Blocking login for user with bad email domain: %s (email: %s)", user.Id, user.Email)) + return "Your account violates community guidelines. Please contact an administrator." + } + + return "" // Allow login +} + // Plugin Callback: UserHasBeenCreated // Executed after a user has been created, no return expected func (p *Plugin) UserHasBeenCreated(_ *plugin.Context, user *model.User) { @@ -140,16 +173,18 @@ func (p *Plugin) UserHasBeenCreated(_ *plugin.Context, user *model.User) { original := *user // Perform the cleanup operation - if !p.cleanupUser(user) { - fmt.Println("Something went wrong when cleaning up user: ", original) + p.API.LogInfo(fmt.Sprintf("User validation failed for user %s (username: %s, email: %s), starting cleanup", original.Id, original.Username, original.Email)) + if err := p.cleanupUser(user); err != nil { + p.API.LogError(fmt.Sprintf("Error cleaning up user %s (username: %s, email: %s): %v", original.Id, original.Username, original.Email, err)) + // Continue - we still want to log validation errors + } else { + p.API.LogInfo(fmt.Sprintf("Successfully cleaned up user %s (username: %s, email: %s)", original.Id, original.Username, original.Email)) } - // TODO: do something with the validation errors i.e. send them somewhere + // Log validation errors for moderation review for _, err := range validationErrors { - fmt.Println(err) + p.API.LogError(fmt.Sprintf("User validation failed: %v (user: %s, email: %s, userID: %s)", err, original.Username, original.Email, original.Id)) } - - fmt.Printf("user info: %v\n", original) } func (p *Plugin) RequiresModeration(user *model.User, validators ...func(*model.User) error) []error { @@ -167,45 +202,415 @@ func (p *Plugin) RequiresModeration(user *model.User, validators ...func(*model. return nil // Does not require moderation } -func (p *Plugin) RemoveUserFromTeams(user *model.User) error { - teams, err := p.API.GetTeamsForUser(user.Id) - if err != nil { - return fmt.Errorf("unable to get any teams for user") +// getSystemAdminUser finds a system admin user to use for administrative operations +func (p *Plugin) getSystemAdminUser() (*model.User, error) { + config := p.getConfiguration() + + // First, try the configured admin username if provided + // Dev Note: Use if you want a specific admin user to be the user on record + // for disabling user accounts. + if config.AdminUsername != "" { + p.API.LogInfo(fmt.Sprintf("Looking for configured admin user: %s", config.AdminUsername)) + user, appErr := p.API.GetUserByUsername(config.AdminUsername) + if appErr == nil && user != nil && user.DeleteAt == 0 { + // Check if user has system admin role + if user.Roles == "system_admin" || strings.Contains(user.Roles, "system_admin") { + p.API.LogInfo(fmt.Sprintf("Found configured admin user: %s (ID: %s)", config.AdminUsername, user.Id)) + return user, nil + } + // If configured user doesn't have admin role, return error (don't fall back) + p.API.LogError(fmt.Sprintf("Configured admin user '%s' does not have system_admin role (has: %s)", config.AdminUsername, user.Roles)) + return nil, fmt.Errorf("configured admin user '%s' does not have system_admin role", config.AdminUsername) + } + if appErr != nil { + p.API.LogError(fmt.Sprintf("Configured admin user '%s' not found, will try role-based search: %v", config.AdminUsername, appErr)) + } + // If configured user not found, fall through to role-based search + // (This allows graceful fallback if the configured admin is temporarily unavailable) } - admin, err := p.API.GetUserByUsername("neil") // TODO should not just use me + // If no configured admin or it wasn't found, search for users by system_admin role + p.API.LogInfo("Searching for system admin users by role") + options := &model.UserGetOptions{ + Role: "system_admin", + } + users, appErr := p.API.GetUsers(options) + if appErr != nil { + p.API.LogError(fmt.Sprintf("Failed to get users by role: %v", appErr)) + return nil, fmt.Errorf("failed to get users by role: %w", appErr) + } + + // Find the first non-deleted system admin user + for _, user := range users { + if user != nil && user.DeleteAt == 0 { + p.API.LogInfo(fmt.Sprintf("Found system admin user via role-based search: %s (ID: %s)", user.Username, user.Id)) + return user, nil + } + } + + // If no admin found, return an error + p.API.LogError("No system admin user found (tried configured admin and role-based search)") + return nil, fmt.Errorf("no system admin user found") +} + +// UserHasJoinedTeam is called when a user joins a team +// This hook allows us to immediately remove users who failed validation +// Signature: (c *plugin.Context, teamMember *model.TeamMember, actor *model.User) +func (p *Plugin) UserHasJoinedTeam(_ *plugin.Context, teamMember *model.TeamMember, _ *model.User) { + userID := teamMember.UserId + teamID := teamMember.TeamId + + p.API.LogInfo(fmt.Sprintf("UserHasJoinedTeam hook: user %s joined team %s", userID, teamID)) + + user, err := p.GetUserByID(userID) if err != nil { - return fmt.Errorf("failed to get admin by username to perform removal") + p.API.LogError(fmt.Sprintf("Failed to get user %s in UserHasJoinedTeam hook: %v", userID, err)) + return + } + + // Check if user should be blocked (soft-deleted, bad username, or bad email) + shouldBlock := false + reason := "" + + if user.DeleteAt > 0 { + shouldBlock = true + reason = "user is soft-deleted" + } else if err := p.checkBadUsername(user); err != nil { + shouldBlock = true + reason = fmt.Sprintf("bad username: %s", user.Username) + } else if err := p.checkBadEmail(user); err != nil { + shouldBlock = true + reason = fmt.Sprintf("bad email domain: %s", user.Email) } + if shouldBlock { + p.API.LogWarn(fmt.Sprintf("SECURITY: Bad user %s attempting to join team %s (reason: %s)", userID, teamID, reason)) + p.removeUserFromTeam(userID, teamID) + } +} + +// removeUserFromTeam removes a user from a specific team +func (p *Plugin) removeUserFromTeam(userID string, teamID string) { + admin, adminErr := p.getSystemAdminUser() + if adminErr != nil { + p.API.LogError(fmt.Sprintf("SECURITY: Failed to get admin user for removing user %s from team %s: %v", userID, teamID, adminErr)) + + // Try to remove without admin ID (may work in some Mattermost configurations) + if err := p.API.DeleteTeamMember(teamID, userID, ""); err != nil { + p.API.LogError(fmt.Sprintf("CRITICAL: Failed to remove bad user %s from team %s (admin lookup failed AND direct removal failed): %v", userID, teamID, err)) + // TODO: Add to pending-removal queue or alert admins + } else { + p.API.LogInfo(fmt.Sprintf("Successfully removed user %s from team %s (without admin context)", userID, teamID)) + } + return + } + + if err := p.API.DeleteTeamMember(teamID, userID, admin.Id); err != nil { + p.API.LogError(fmt.Sprintf("CRITICAL: Failed to remove user %s from team %s: %v", userID, teamID, err)) + } else { + p.API.LogInfo(fmt.Sprintf("Successfully removed user %s from team %s", userID, teamID)) + } +} + +func (p *Plugin) RemoveUserFromTeams(user *model.User) error { + p.API.LogInfo(fmt.Sprintf("Attempting to remove user %s (username: %s) from teams", user.Id, user.Username)) + + teams, appErr := p.API.GetTeamsForUser(user.Id) + if appErr != nil { + // GetTeamsForUser may return an error in different scenarios + // Check if it's a "not found" type error (user has no teams) vs a real API error + errorMsg := strings.ToLower(appErr.Error()) + + // Mattermost API typically returns errors for "not found" scenarios + // Check the error message for common "not found" patterns + // Also check if the error ID indicates a not found scenario + isNotFound := strings.Contains(errorMsg, "not found") || + strings.Contains(errorMsg, "no teams") || + strings.Contains(errorMsg, "does not exist") || + strings.Contains(errorMsg, "not_found") || + (appErr.Id != "" && strings.Contains(strings.ToLower(appErr.Id), "not_found")) + + if isNotFound { + p.API.LogInfo(fmt.Sprintf("User %s is not in any teams (error: %s, id: %s), nothing to remove", user.Id, errorMsg, appErr.Id)) + return nil // User has no teams, that's fine + } + // This is a real API error - log it and return it + p.API.LogError(fmt.Sprintf("Failed to get teams for user %s: error=%v, id=%s, message=%s", user.Id, appErr, appErr.Id, errorMsg)) + return fmt.Errorf("failed to get teams for user: %w", appErr) + } + + if len(teams) == 0 { + p.API.LogInfo(fmt.Sprintf("User %s is not in any teams (empty list returned)", user.Id)) + return nil // User not in any teams + } + + p.API.LogInfo(fmt.Sprintf("User %s is in %d team(s), removing from all teams", user.Id, len(teams))) + + admin, adminErr := p.getSystemAdminUser() + if adminErr != nil { + p.API.LogError(fmt.Sprintf("Failed to get system admin user for removing user %s from teams: %v", user.Id, adminErr)) + return fmt.Errorf("failed to get system admin user: %w", adminErr) + } + + p.API.LogInfo(fmt.Sprintf("Using admin user %s (ID: %s) to remove user %s from teams", admin.Username, admin.Id, user.Id)) + + removedCount := 0 + failedRemovals := []string{} for _, team := range teams { + teamName := team.Name + if teamName == "" { + teamName = "" + } + p.API.LogInfo(fmt.Sprintf("Removing user %s from team %s (ID: %s)", user.Id, teamName, team.Id)) if err := p.API.DeleteTeamMember(team.Id, user.Id, admin.Id); err != nil { - return fmt.Errorf("failed to remove user from team: (%v, %v)", user, team) + errorDetails := fmt.Sprintf("error=%v", err) + if err.Id != "" { + errorDetails += fmt.Sprintf(", id=%s", err.Id) + } + p.API.LogError(fmt.Sprintf("Failed to remove user %s from team %s (ID: %s): %s", user.Id, teamName, team.Id, errorDetails)) + failedRemovals = append(failedRemovals, fmt.Sprintf("%s (%s)", teamName, team.Id)) + // Continue removing from other teams even if one fails + } else { + removedCount++ + p.API.LogInfo(fmt.Sprintf("Successfully removed user %s from team %s (ID: %s)", user.Id, teamName, team.Id)) } } + + if len(failedRemovals) > 0 { + p.API.LogError(fmt.Sprintf("Failed to remove user %s from %d team(s): %v", user.Id, len(failedRemovals), failedRemovals)) + return fmt.Errorf("failed to remove user from %d team(s): %v", len(failedRemovals), failedRemovals) + } + + p.API.LogInfo(fmt.Sprintf("Successfully removed user %s from %d team(s)", user.Id, removedCount)) return nil } -func (p *Plugin) cleanupUser(user *model.User) bool { - // Clean the user's attributes - user.Nickname = fmt.Sprintf("sanitized-%s", user.Id) - user.Username = fmt.Sprintf("sanitized-%s", user.Id) +func (p *Plugin) cleanupUser(user *model.User) error { + p.API.LogInfo(fmt.Sprintf("Starting cleanup for user %s (username: %s, email: %s)", user.Id, user.Username, user.Email)) + + // Remove user from teams + // Note: If user has no teams yet (race condition), the UserHasJoinedTeam hook will catch them + p.API.LogInfo(fmt.Sprintf("Attempting to remove user %s from teams", user.Id)) + if err := p.RemoveUserFromTeams(user); err != nil { + // Log error but continue - deletion is more important + // The UserHasJoinedTeam hook will catch any teams they join later + p.API.LogError(fmt.Sprintf("Failed to remove user %s from teams (will continue with deletion, UserHasJoinedTeam hook will catch future teams): %v", user.Id, err)) + } else { + p.API.LogInfo(fmt.Sprintf("Successfully removed user %s from all teams (or user had no teams - UserHasJoinedTeam hook will catch future teams)", user.Id)) + } - fmt.Println(p.API) - user, err := p.API.UpdateUser(user) + // Delete them - Perform a soft delete so the account _can_ be restored. + p.API.LogInfo(fmt.Sprintf("Soft-deleting user %s", user.Id)) + if err := p.API.DeleteUser(user.Id); err != nil { + p.API.LogError(fmt.Sprintf("Failed to soft-delete user %s: %v", user.Id, err)) + return fmt.Errorf("unable to deactivate user: %w", err) + } + p.API.LogInfo(fmt.Sprintf("Successfully soft-deleted user %s", user.Id)) + + // Clear cache after deletion + if p.cache != nil { + p.cache.Remove(user.Id) + } + + // Verify user is actually deleted + // Note: GetUser may return an error if user is deleted, which is what we want + // Only verify if DeleteUser succeeded (we already returned error if it failed) + verifyUser, verifyErr := p.API.GetUser(user.Id) + if verifyErr == nil && verifyUser != nil && verifyUser.DeleteAt == 0 { + // User still exists - this shouldn't happen if DeleteUser succeeded + // But we'll log it as a warning rather than failing the entire operation + p.API.LogError(fmt.Sprintf("User deletion verification failed: user %s still exists (DeleteAt=%d)", user.Id, verifyUser.DeleteAt)) + } else if verifyUser != nil && verifyUser.DeleteAt > 0 { + p.API.LogInfo(fmt.Sprintf("User deletion verified: user %s has DeleteAt=%d", user.Id, verifyUser.DeleteAt)) + } + + return nil +} + +// FilterNewUserLinks checks if a new user is trying to post links and blocks them if they're too new +func (p *Plugin) FilterNewUserLinks(configuration *configuration, post *model.Post) (*model.Post, string) { + return p.filterNewUserContent( + post, + "links", + configuration.BlockNewUserLinksTime, + "Configuration settings limit new users from posting links.", + ) +} + +// FilterNewUserImages checks if a new user is trying to post images and blocks them if they're too new +func (p *Plugin) FilterNewUserImages(configuration *configuration, post *model.Post) (*model.Post, string) { + return p.filterNewUserContent( + post, + "images", + configuration.BlockNewUserImagesTime, + "Configuration settings limit new users from posting images.", + ) +} + +// containsLinks checks if a post contains links +func (p *Plugin) containsLinks(post *model.Post) bool { + // Check if the post has embeds (which includes OpenGraph metadata for links) + if post.Metadata != nil && len(post.Metadata.Embeds) > 0 { + return true + } + + // Check if the post message contains URLs + // This is a simple regex to detect URLs in the message + urlRegex := regexp.MustCompile(`https?://[^\s<>"]+|www\.[^\s<>"]+`) + return urlRegex.MatchString(post.Message) +} + +// isImageExtension checks if a file extension corresponds to an image format +// Extension should be normalized (lowercase, no leading dot) but the function handles common variations +func (p *Plugin) isImageExtension(extension string) bool { + // Normalize extension by removing dot prefix and converting to lowercase + ext := strings.ToLower(strings.TrimPrefix(extension, ".")) + + // Check if the extension matches known image formats + return ext == "jpg" || ext == "jpeg" || ext == "png" || ext == "gif" || ext == "bmp" || ext == "webp" || + ext == "svg" || ext == "tiff" || ext == "tif" || ext == "ico" || ext == "heic" || ext == "heif" || ext == "avif" +} + +// containsImages checks if a post contains images +func (p *Plugin) containsImages(post *model.Post) bool { + // Check if the post has file attachments that are images + // First check Metadata.Files (populated after post processing) + if post.Metadata != nil && len(post.Metadata.Files) > 0 { + for _, file := range post.Metadata.Files { + if p.isImageExtension(file.Extension) { + return true + } + } + } + + // Check FileIds (available when MessageWillBePosted is called) + // Metadata.Files may not be populated yet at hook time, but FileIds are + if len(post.FileIds) > 0 { + for _, fileID := range post.FileIds { + fileInfo, err := p.API.GetFileInfo(fileID) + if err != nil { + // If we can't get file info, continue checking other files + continue + } + if p.isImageExtension(fileInfo.Extension) { + return true + } + } + } + + // Check if the post has image embeds + if post.Metadata != nil && len(post.Metadata.Images) > 0 { + return true + } + + // Check if the post message contains Markdown image syntax + imageRegex := regexp.MustCompile(`!\[.*?\]\(.*?\)`) + return imageRegex.MatchString(post.Message) +} + +// isUserTooNew checks if a user is too new based on the configured duration +// Returns (isTooNew, errorMessage, error) +func (p *Plugin) isUserTooNew(user *model.User, blockDuration string, contentType string) (bool, string, error) { + // Check if the filter is enabled indefinitely (duration is -1) + if blockDuration == "-1" { + return true, fmt.Sprintf("New user not allowed to post %s indefinitely.", contentType), nil + } + + createdAt := time.UnixMilli(user.CreateAt) + duration, parseErr := time.ParseDuration(blockDuration) + + if parseErr != nil { + return false, "", fmt.Errorf("failed to parse duration: %w", parseErr) + } + + if time.Since(createdAt) < duration { + return true, fmt.Sprintf("New user not allowed to post %s for %s.", contentType, duration), nil + } + + return false, "", nil +} + +// getUserAndHandleError retrieves a user by ID and handles any errors +func (p *Plugin) getUserAndHandleError(userID string, post *model.Post) (*model.User, string) { + user, err := p.GetUserByID(userID) if err != nil { - fmt.Printf("Unable to sanitize user") + p.sendUserEphemeralMessageForPost(post, "Something went wrong when sending your message. Contact an administrator.") + return nil, "Failed to get user" } + return user, "" +} - // Remove user from teams - if err := p.RemoveUserFromTeams(user); err != nil { - fmt.Printf("Unable to remove user from teams: %v\n", err) +// handleFilterError handles errors from the isUserTooNew function +func (p *Plugin) handleFilterError(err error, post *model.Post) (*model.Post, string) { + if err != nil { + p.sendUserEphemeralMessageForPost(post, "Something went wrong when sending your message. Contact an administrator.") + return nil, err.Error() + } + return nil, "" +} + +// isSystemAdmin checks if a user has system admin role +func (p *Plugin) isSystemAdmin(user *model.User) bool { + return user != nil && (user.Roles == "system_admin" || strings.Contains(user.Roles, "system_admin")) +} + +// filterNewUserContent is a generic function to filter content from new users +func (p *Plugin) filterNewUserContent(post *model.Post, contentType string, blockDuration string, userMessage string) (*model.Post, string) { + user, errMsg := p.getUserAndHandleError(post.UserId, post) + if errMsg != "" { + return nil, errMsg + } + + // Exempt system admins from new user restrictions + if p.isSystemAdmin(user) { + return post, "" + } + + isTooNew, errorMsg, err := p.isUserTooNew(user, blockDuration, contentType) + if err != nil { + return p.handleFilterError(err, post) + } + + if isTooNew { + p.sendUserEphemeralMessageForPost(post, userMessage) + return nil, errorMsg + } + + return post, "" +} + +// shouldBlockUserPost checks if a user should be blocked from posting +// based on bad username or email domain validation or if user is soft-deleted +// Returns true if the user should be blocked +func (p *Plugin) shouldBlockUserPost(userID string) bool { + // Skip validation if userID is empty or API is not available + if userID == "" || p.API == nil { + return false + } + + user, err := p.GetUserByID(userID) + if err != nil { + // If we can't get the user, don't block (let other errors handle it) + return false + } + + // Check if user is soft-deleted (deactivated) + if user.DeleteAt > 0 { + p.API.LogDebug(fmt.Sprintf("Blocking post from soft-deleted user: %s", user.Id)) + return true + } + + // Check if user has bad username + if err := p.checkBadUsername(user); err != nil { + p.API.LogWarn(fmt.Sprintf("Blocking post from user %s with bad username: %s", user.Id, user.Username)) + return true } - // delete them - Perform a soft delete so the account _can_ be restored. - if err = p.API.DeleteUser(user.Id); err != nil { - fmt.Printf("unable to deactivate user: %v", err) + // Check if user has bad email domain + if err := p.checkBadEmail(user); err != nil { + p.API.LogWarn(fmt.Sprintf("Blocking post from user %s with bad email domain: %s", user.Id, user.Email)) + return true } - return true + return false } diff --git a/server/plugin_test.go b/server/plugin_test.go index 419868b..768a5c3 100644 --- a/server/plugin_test.go +++ b/server/plugin_test.go @@ -1,7 +1,7 @@ package main import ( - // "fmt" + "fmt" "regexp" "sync" "testing" @@ -14,6 +14,11 @@ import ( // "github.com/mattermost/mattermost/server/public/shared/request" ) +// boolPtr is a helper to track boolean state in closures +type boolPtr struct { + value bool +} + func TestMessageWillBePosted(t *testing.T) { p := Plugin{ configuration: &configuration{ @@ -108,7 +113,14 @@ func TestMessageWillBePosted(t *testing.T) { type MockAPI struct { plugin.API - UpdateUserFunc func(user *model.User) (*model.User, *model.AppError) + UpdateUserFunc func(user *model.User) (*model.User, *model.AppError) + GetFileInfoFunc func(fileID string) (*model.FileInfo, *model.AppError) + GetUserFunc func(userID string) (*model.User, *model.AppError) + GetTeamsForUserFunc func(userID string) ([]*model.Team, *model.AppError) + GetUserByUsernameFunc func(username string) (*model.User, *model.AppError) + GetUsersFunc func(options *model.UserGetOptions) ([]*model.User, *model.AppError) + DeleteTeamMemberFunc func(teamID string, userID string, adminID string) *model.AppError + DeleteUserFunc func(userID string) *model.AppError } func (m *MockAPI) UpdateUser(user *model.User) (*model.User, *model.AppError) { @@ -119,21 +131,74 @@ func (m *MockAPI) UpdateUser(user *model.User) (*model.User, *model.AppError) { } func (m *MockAPI) DeleteUser(userID string) *model.AppError { + if m.DeleteUserFunc != nil { + return m.DeleteUserFunc(userID) + } return nil } func (m *MockAPI) GetTeamsForUser(userID string) ([]*model.Team, *model.AppError) { + if m.GetTeamsForUserFunc != nil { + return m.GetTeamsForUserFunc(userID) + } return nil, nil } func (m *MockAPI) GetUserByUsername(userName string) (*model.User, *model.AppError) { + if m.GetUserByUsernameFunc != nil { + return m.GetUserByUsernameFunc(userName) + } + return nil, nil +} + +func (m *MockAPI) GetUsers(options *model.UserGetOptions) ([]*model.User, *model.AppError) { + if m.GetUsersFunc != nil { + return m.GetUsersFunc(options) + } return nil, nil } +func (m *MockAPI) GetUser(userID string) (*model.User, *model.AppError) { + if m.GetUserFunc != nil { + return m.GetUserFunc(userID) + } + return &model.User{Id: userID}, nil +} + func (m *MockAPI) DeleteTeamMember(teamID string, userID string, adminID string) *model.AppError { + if m.DeleteTeamMemberFunc != nil { + return m.DeleteTeamMemberFunc(teamID, userID, adminID) + } return nil } +func (m *MockAPI) SendEphemeralPost(userID string, post *model.Post) *model.Post { + return post +} + +func (m *MockAPI) GetFileInfo(fileID string) (*model.FileInfo, *model.AppError) { + if m.GetFileInfoFunc != nil { + return m.GetFileInfoFunc(fileID) + } + return nil, &model.AppError{Message: "file not found"} +} + +func (m *MockAPI) LogInfo(msg string, keyValuePairs ...interface{}) { + // No-op for testing +} + +func (m *MockAPI) LogError(msg string, keyValuePairs ...interface{}) { + // No-op for testing +} + +func (m *MockAPI) LogDebug(msg string, keyValuePairs ...interface{}) { + // No-op for testing +} + +func (m *MockAPI) LogWarn(msg string, keyValuePairs ...interface{}) { + // No-op for testing +} + func TestUserHasBeenCreated(t *testing.T) { p := Plugin{ configuration: &configuration{ @@ -149,12 +214,15 @@ func TestUserHasBeenCreated(t *testing.T) { }, } p.SetAPI(&MockAPI{}) - p.badDomainsRegex = regexp.MustCompile(wordListToRegex(p.getConfiguration().BadDomainsList, defaultRegexTemplate)) + p.badDomainsRegex = regexp.MustCompile(wordListToRegex(p.getConfiguration().BadDomainsList, `(?mi)(%s)`)) p.badUsernamesRegex = regexp.MustCompile(wordListToRegex(p.getConfiguration().BadUsernamesList, `(?mi)(%s)`)) - _ = p.setupBadDomainList() + // setupBadDomainList should succeed as the embedded file is valid JSON + err := p.setupBadDomainList() + assert.NoError(t, err, "setupBadDomainList should succeed with valid embedded JSON") t.Run("username matching word is banned", func(_ *testing.T) { id := model.NewId() + adminID := model.NewId() user := &model.User{ Id: id, Email: id + "@gooddomain.com", @@ -162,15 +230,41 @@ func TestUserHasBeenCreated(t *testing.T) { Username: "ihateneil-" + id, Password: "passwd12345", } - original := *user + + userDeleted := false + mockAPI := &MockAPI{ + GetUserByUsernameFunc: func(username string) (*model.User, *model.AppError) { + if username == "admin" { + return &model.User{Id: adminID, Username: "admin", Roles: "system_admin"}, nil + } + return nil, model.NewAppError("GetUserByUsername", "user not found", nil, "", 404) + }, + GetTeamsForUserFunc: func(userID string) ([]*model.Team, *model.AppError) { + return []*model.Team{}, nil + }, + DeleteUserFunc: func(userID string) *model.AppError { + if userID == id { + userDeleted = true + } + return nil + }, + GetUserFunc: func(userID string) (*model.User, *model.AppError) { + if userID == id && userDeleted { + return &model.User{Id: id, DeleteAt: model.GetMillis()}, nil + } + return &model.User{Id: userID}, nil + }, + } + p.SetAPI(mockAPI) p.UserHasBeenCreated(&plugin.Context{}, user) - assert.NotEqual(t, user.Username, original.Username) + assert.True(t, userDeleted, "User should be soft-deleted when username matches bad words") }) t.Run("nickname matching word is banned", func(_ *testing.T) { id := model.NewId() + adminID := model.NewId() user := &model.User{ Id: id, Email: id + "@gooddomain.com", @@ -178,17 +272,43 @@ func TestUserHasBeenCreated(t *testing.T) { Username: "reasonable-" + id, Password: "passwd12345", } - original := *user + + userDeleted := false + mockAPI := &MockAPI{ + GetUserByUsernameFunc: func(username string) (*model.User, *model.AppError) { + if username == "admin" { + return &model.User{Id: adminID, Username: "admin", Roles: "system_admin"}, nil + } + return nil, model.NewAppError("GetUserByUsername", "user not found", nil, "", 404) + }, + GetTeamsForUserFunc: func(userID string) ([]*model.Team, *model.AppError) { + return []*model.Team{}, nil + }, + DeleteUserFunc: func(userID string) *model.AppError { + if userID == id { + userDeleted = true + } + return nil + }, + GetUserFunc: func(userID string) (*model.User, *model.AppError) { + if userID == id && userDeleted { + return &model.User{Id: id, DeleteAt: model.GetMillis()}, nil + } + return &model.User{Id: userID}, nil + }, + } + p.SetAPI(mockAPI) p.UserHasBeenCreated(&plugin.Context{}, user) - assert.NotEqual(t, user.Username, original.Username) + assert.True(t, userDeleted, "User should be soft-deleted when nickname matches bad words") }) // NOTE(nhanlon): 2024-03-20 I'm not sure if this test is really necessary, but I'm including it to // highlight that your badDomain list must be curated carefully and the combinations of potential word bits. t.Run("user matching word stub is banned", func(_ *testing.T) { id := model.NewId() + adminID := model.NewId() user := &model.User{ Id: id, Email: id + "@gooddomain.com", @@ -196,11 +316,36 @@ func TestUserHasBeenCreated(t *testing.T) { Username: "shakeoffthehaters-" + id, Password: "passwd12345", } - original := *user + + userDeleted := false + mockAPI := &MockAPI{ + GetUserByUsernameFunc: func(username string) (*model.User, *model.AppError) { + if username == "admin" { + return &model.User{Id: adminID, Username: "admin", Roles: "system_admin"}, nil + } + return nil, model.NewAppError("GetUserByUsername", "user not found", nil, "", 404) + }, + GetTeamsForUserFunc: func(userID string) ([]*model.Team, *model.AppError) { + return []*model.Team{}, nil + }, + DeleteUserFunc: func(userID string) *model.AppError { + if userID == id { + userDeleted = true + } + return nil + }, + GetUserFunc: func(userID string) (*model.User, *model.AppError) { + if userID == id && userDeleted { + return &model.User{Id: id, DeleteAt: model.GetMillis()}, nil + } + return &model.User{Id: userID}, nil + }, + } + p.SetAPI(mockAPI) p.UserHasBeenCreated(&plugin.Context{}, user) - assert.NotEqual(t, user.Username, original.Username) + assert.True(t, userDeleted, "User should be soft-deleted when username contains hate substring") }) t.Run("email matching word stub is not banned", func(_ *testing.T) { @@ -221,6 +366,7 @@ func TestUserHasBeenCreated(t *testing.T) { t.Run("email matching email in default list banned", func(_ *testing.T) { id := model.NewId() + adminID := model.NewId() user := &model.User{ Id: id, Email: id + "@hoo.com", @@ -228,15 +374,41 @@ func TestUserHasBeenCreated(t *testing.T) { Username: "alright-" + id, Password: "passwd12345", } - original := *user + + userDeleted := false + mockAPI := &MockAPI{ + GetUserByUsernameFunc: func(username string) (*model.User, *model.AppError) { + if username == "admin" { + return &model.User{Id: adminID, Username: "admin", Roles: "system_admin"}, nil + } + return nil, model.NewAppError("GetUserByUsername", "user not found", nil, "", 404) + }, + GetTeamsForUserFunc: func(userID string) ([]*model.Team, *model.AppError) { + return []*model.Team{}, nil + }, + DeleteUserFunc: func(userID string) *model.AppError { + if userID == id { + userDeleted = true + } + return nil + }, + GetUserFunc: func(userID string) (*model.User, *model.AppError) { + if userID == id && userDeleted { + return &model.User{Id: id, DeleteAt: model.GetMillis()}, nil + } + return &model.User{Id: userID}, nil + }, + } + p.SetAPI(mockAPI) p.UserHasBeenCreated(&plugin.Context{}, user) - assert.NotEqual(t, user.Username, original.Username) + assert.True(t, userDeleted, "User should be soft-deleted when email matches builtin bad domain list") }) t.Run("user matching email is banned", func(_ *testing.T) { id := model.NewId() + adminID := model.NewId() user := &model.User{ Id: id, Email: id + "@baddomain.com", @@ -244,13 +416,36 @@ func TestUserHasBeenCreated(t *testing.T) { Username: "neilfan-" + id, Password: "passwd12345", } - original := *user - p.UserHasBeenCreated(&plugin.Context{}, user) + userDeleted := false + mockAPI := &MockAPI{ + GetUserByUsernameFunc: func(username string) (*model.User, *model.AppError) { + if username == "admin" { + return &model.User{Id: adminID, Username: "admin", Roles: "system_admin"}, nil + } + return nil, model.NewAppError("GetUserByUsername", "user not found", nil, "", 404) + }, + GetTeamsForUserFunc: func(userID string) ([]*model.Team, *model.AppError) { + return []*model.Team{}, nil + }, + DeleteUserFunc: func(userID string) *model.AppError { + if userID == id { + userDeleted = true + } + return nil + }, + GetUserFunc: func(userID string) (*model.User, *model.AppError) { + if userID == id && userDeleted { + return &model.User{Id: id, DeleteAt: model.GetMillis()}, nil + } + return &model.User{Id: userID}, nil + }, + } + p.SetAPI(mockAPI) - time.Sleep(1 * time.Second) + p.UserHasBeenCreated(&plugin.Context{}, user) - assert.NotEqual(t, user.Username, original.Username) + assert.True(t, userDeleted, "User should be soft-deleted when email matches bad domain") }) t.Run("user not matching email nor name is not banned", func(_ *testing.T) { @@ -286,167 +481,1374 @@ func TestUserHasBeenCreated(t *testing.T) { }) } -// Extended MockAPI for FilterDirectMessage tests -type ExtendedMockAPI struct { - MockAPI - GetUserFunc func(userID string) (*model.User, *model.AppError) - GetChannelFunc func(channelID string) (*model.Channel, *model.AppError) - SendEphemeralPostFunc func(userID string, post *model.Post) *model.Post -} - -func (m *ExtendedMockAPI) GetUser(userID string) (*model.User, *model.AppError) { - if m.GetUserFunc != nil { - return m.GetUserFunc(userID) - } - return &model.User{Id: userID}, nil -} - -func (m *ExtendedMockAPI) GetChannel(channelID string) (*model.Channel, *model.AppError) { - if m.GetChannelFunc != nil { - return m.GetChannelFunc(channelID) +func TestGetSystemAdminUser(t *testing.T) { + p := Plugin{ + configuration: &configuration{}, } - return &model.Channel{Id: channelID, Type: model.ChannelTypeDirect}, nil -} -func (m *ExtendedMockAPI) SendEphemeralPost(userID string, post *model.Post) *model.Post { - if m.SendEphemeralPostFunc != nil { - return m.SendEphemeralPostFunc(userID, post) - } - return post -} + t.Run("uses configured admin username when provided", func(t *testing.T) { + p.configuration = &configuration{ + AdminUsername: "custom-admin", + } -func TestFilterDirectMessage(t *testing.T) { - t.Run("blocks new user within time restriction", func(t *testing.T) { - p := Plugin{ - configuration: &configuration{ - BlockNewUserPM: true, - BlockNewUserPMTime: "24h", + adminID := model.NewId() + mockAPI := &MockAPI{ + GetUserByUsernameFunc: func(username string) (*model.User, *model.AppError) { + if username == "custom-admin" { + return &model.User{Id: adminID, Username: "custom-admin", Roles: "system_admin"}, nil + } + return nil, model.NewAppError("GetUserByUsername", "user not found", nil, "", 404) }, - cache: NewLRUCache(10), } + p.SetAPI(mockAPI) - // User created 1 hour ago - oneHourAgo := model.GetMillis() - (60 * 60 * 1000) - testUser := &model.User{ - Id: "new-user", - CreateAt: oneHourAgo, + admin, err := p.getSystemAdminUser() + assert.NoError(t, err) + assert.NotNil(t, admin) + assert.Equal(t, "custom-admin", admin.Username) + assert.Equal(t, adminID, admin.Id) + }) + + t.Run("returns error if configured admin username doesn't have admin role", func(t *testing.T) { + p.configuration = &configuration{ + AdminUsername: "regular-user", } - ephemeralSent := false - p.SetAPI(&ExtendedMockAPI{ - GetUserFunc: func(userID string) (*model.User, *model.AppError) { - if userID == "new-user" { - return testUser, nil + mockAPI := &MockAPI{ + GetUserByUsernameFunc: func(username string) (*model.User, *model.AppError) { + if username == "regular-user" { + return &model.User{Id: model.NewId(), Username: "regular-user", Roles: "system_user"}, nil } - return nil, model.NewAppError("GetUser", "user not found", nil, "", 404) - }, - SendEphemeralPostFunc: func(userID string, post *model.Post) *model.Post { - ephemeralSent = true - assert.Equal(t, "Configuration settings limit new users from sending private messages.", post.Message) - return post + return nil, model.NewAppError("GetUserByUsername", "user not found", nil, "", 404) }, - }) - - post := &model.Post{ - UserId: "new-user", - ChannelId: "dm-channel", - Message: "Hello", } + p.SetAPI(mockAPI) - resultPost, rejectReason := p.FilterDirectMessage(p.configuration, post) - - assert.Nil(t, resultPost) - assert.Contains(t, rejectReason, "New user not allowed to send DM") - assert.True(t, ephemeralSent) + admin, err := p.getSystemAdminUser() + assert.Error(t, err) + assert.Nil(t, admin) + assert.Contains(t, err.Error(), "does not have system_admin role") }) - t.Run("allows user past time restriction", func(t *testing.T) { - p := Plugin{ - configuration: &configuration{ - BlockNewUserPM: true, - BlockNewUserPMTime: "24h", + t.Run("falls back to role-based search when configured admin not found", func(t *testing.T) { + p.configuration = &configuration{ + AdminUsername: "nonexistent-admin", + } + + adminID := model.NewId() + mockAPI := &MockAPI{ + GetUserByUsernameFunc: func(username string) (*model.User, *model.AppError) { + if username == "nonexistent-admin" { + return nil, model.NewAppError("GetUserByUsername", "user not found", nil, "", 404) + } + return nil, model.NewAppError("GetUserByUsername", "user not found", nil, "", 404) + }, + GetUsersFunc: func(options *model.UserGetOptions) ([]*model.User, *model.AppError) { + if options != nil && options.Role == "system_admin" { + return []*model.User{ + {Id: adminID, Username: "admin", Roles: "system_admin", DeleteAt: 0}, + }, nil + } + return nil, nil }, - cache: NewLRUCache(10), } + p.SetAPI(mockAPI) - // User created 25 hours ago - twentyFiveHoursAgo := model.GetMillis() - (25 * 60 * 60 * 1000) - testUser := &model.User{ - Id: "old-user", - CreateAt: twentyFiveHoursAgo, + admin, err := p.getSystemAdminUser() + assert.NoError(t, err) + assert.NotNil(t, admin) + assert.Equal(t, "admin", admin.Username) + assert.Equal(t, adminID, admin.Id) + }) + + t.Run("uses role-based search when no admin username configured", func(t *testing.T) { + p.configuration = &configuration{ + AdminUsername: "", // Empty string means not configured } - p.SetAPI(&ExtendedMockAPI{ - GetUserFunc: func(userID string) (*model.User, *model.AppError) { - if userID == "old-user" { - return testUser, nil + adminID := model.NewId() + mockAPI := &MockAPI{ + GetUsersFunc: func(options *model.UserGetOptions) ([]*model.User, *model.AppError) { + if options != nil && options.Role == "system_admin" { + return []*model.User{ + {Id: adminID, Username: "admin", Roles: "system_admin", DeleteAt: 0}, + }, nil } - return nil, model.NewAppError("GetUser", "user not found", nil, "", 404) + return nil, nil }, - }) - - post := &model.Post{ - UserId: "old-user", - ChannelId: "dm-channel", - Message: "Hello", } + p.SetAPI(mockAPI) - resultPost, rejectReason := p.FilterDirectMessage(p.configuration, post) - - assert.Equal(t, post, resultPost) - assert.Empty(t, rejectReason) + admin, err := p.getSystemAdminUser() + assert.NoError(t, err) + assert.NotNil(t, admin) + assert.Equal(t, "admin", admin.Username) + assert.Equal(t, adminID, admin.Id) }) +} - t.Run("handles invalid duration format", func(t *testing.T) { - p := Plugin{ - configuration: &configuration{ - BlockNewUserPM: true, - BlockNewUserPMTime: "invalid-duration", - }, - cache: NewLRUCache(10), +func TestCleanupUser(t *testing.T) { + badUsernamesRegex, _ := splitWordListToRegex("baduser", `(?mi)(%s)`) + badDomainsRegex, _ := splitWordListToRegex("baddomain.com", `(?mi)(%s)`) + + p := Plugin{ + configuration: &configuration{ + BadUsernamesList: "baduser", + BadDomainsList: "baddomain.com", + }, + cache: NewLRUCache(50), + badUsernamesRegex: badUsernamesRegex, + badDomainsRegex: badDomainsRegex, + } + + t.Run("cleanupUser removes user from teams and deletes them", func(t *testing.T) { + userID := model.NewId() + teamID := model.NewId() + adminID := model.NewId() + user := &model.User{ + Id: userID, + Username: "baduser", + Email: "test@gooddomain.com", } - testUser := &model.User{ - Id: "user", - CreateAt: model.GetMillis(), + // Use pointers to track state across closures + teamsRemoved := []string{} + userDeleted := &boolPtr{value: false} + userUpdated := &boolPtr{value: false} + deleteUserCalled := &boolPtr{value: false} + + // Clear cache to ensure we use the API + if p.cache != nil { + p.cache.Remove(userID) } - ephemeralSent := false - p.SetAPI(&ExtendedMockAPI{ - GetUserFunc: func(userID string) (*model.User, *model.AppError) { - return testUser, nil + mockAPI := &MockAPI{ + GetTeamsForUserFunc: func(id string) ([]*model.Team, *model.AppError) { + if id == userID { + return []*model.Team{{Id: teamID}}, nil + } + return nil, nil }, - SendEphemeralPostFunc: func(userID string, post *model.Post) *model.Post { - ephemeralSent = true - assert.Equal(t, "Something went wrong when sending your message. Contact an administrator.", post.Message) - return post + GetUsersFunc: func(options *model.UserGetOptions) ([]*model.User, *model.AppError) { + if options != nil && options.Role == "system_admin" { + return []*model.User{ + {Id: adminID, Username: "admin", Roles: "system_admin", DeleteAt: 0}, + }, nil + } + return nil, nil }, - }) + DeleteTeamMemberFunc: func(teamIDParam, userIDParam, adminIDParam string) *model.AppError { + if teamIDParam == teamID && userIDParam == userID { + teamsRemoved = append(teamsRemoved, teamIDParam) + } + return nil + }, + UpdateUserFunc: func(u *model.User) (*model.User, *model.AppError) { + userUpdated.value = true + assert.Equal(t, fmt.Sprintf("sanitized-%s", userID), u.Username) + return u, nil + }, + DeleteUserFunc: func(id string) *model.AppError { + if id == userID { + // Set flags immediately + deleteUserCalled.value = true + userDeleted.value = true + // Return nil to indicate success + return nil + } + return nil + }, + GetUserFunc: func(id string) (*model.User, *model.AppError) { + if id == userID { + // After DeleteUser is called, return deleted user with DeleteAt set + // This simulates Mattermost behavior where deleted users have DeleteAt > 0 + if deleteUserCalled.value { + return &model.User{Id: id, DeleteAt: model.GetMillis()}, nil + } + // Before deletion, return normal user + return &model.User{Id: id, Username: "sanitized-" + id, Email: "test@gooddomain.com", DeleteAt: 0}, nil + } + return &model.User{Id: id}, nil + }, + } + p.SetAPI(mockAPI) - post := &model.Post{ - UserId: "user", - ChannelId: "dm-channel", - Message: "Hello", + err := p.cleanupUser(user) + assert.NoError(t, err) + // NOTE: Username sanitization removed - we no longer call UpdateUser + assert.False(t, userUpdated.value, "User should NOT be updated (no sanitization anymore)") + // Verify DeleteUser was called by checking the flag + // The flag is set synchronously in DeleteUserFunc, so it should be true after cleanupUser returns + if !userDeleted.value { + t.Logf("DeleteUserFunc was not called or flag not set. deleteUserCalled=%v, userDeleted=%v", deleteUserCalled.value, userDeleted.value) + } + assert.True(t, userDeleted.value, "User should be deleted - DeleteUserFunc should have been called") + if len(teamsRemoved) > 0 { + assert.Equal(t, 1, len(teamsRemoved), "User should be removed from team") } + }) - resultPost, rejectReason := p.FilterDirectMessage(p.configuration, post) + t.Run("cleanupUser clears cache after update and deletion", func(t *testing.T) { + userID := model.NewId() + user := &model.User{ + Id: userID, + Username: "baduser", + Email: "test@gooddomain.com", + } - assert.Nil(t, resultPost) - assert.Equal(t, "failed to parse duration", rejectReason) - assert.True(t, ephemeralSent) - }) + // Put user in cache + p.cache.Put(userID, user) + _, found := p.cache.Get(userID) + assert.True(t, found, "User should be in cache initially") - t.Run("handles user not found error", func(t *testing.T) { - p := Plugin{ - configuration: &configuration{ - BlockNewUserPM: true, - BlockNewUserPMTime: "24h", + mockAPI := &MockAPI{ + GetTeamsForUserFunc: func(id string) ([]*model.Team, *model.AppError) { + return nil, nil // No teams + }, + GetUserByUsernameFunc: func(username string) (*model.User, *model.AppError) { + if username == "admin" { + return &model.User{Id: model.NewId(), Roles: "system_admin"}, nil + } + return nil, model.NewAppError("GetUserByUsername", "user not found", nil, "", 404) + }, + UpdateUserFunc: func(u *model.User) (*model.User, *model.AppError) { + return u, nil + }, + DeleteUserFunc: func(id string) *model.AppError { + return nil + }, + GetUserFunc: func(id string) (*model.User, *model.AppError) { + // Return deleted user + return &model.User{Id: id, DeleteAt: model.GetMillis()}, nil }, - cache: NewLRUCache(10), } + p.SetAPI(mockAPI) - ephemeralSent := false - p.SetAPI(&ExtendedMockAPI{ + err := p.cleanupUser(user) + assert.NoError(t, err) + + // Cache should be cleared + _, found = p.cache.Get(userID) + assert.False(t, found, "User should not be in cache after cleanup") + }) + + // NOTE: Test removed - UpdateUser is no longer called since we removed username sanitization + + t.Run("cleanupUser returns error if DeleteUser fails", func(t *testing.T) { + userID := model.NewId() + user := &model.User{ + Id: userID, + Username: "baduser", + Email: "test@gooddomain.com", + } + + // Clear cache to ensure we use the API + if p.cache != nil { + p.cache.Remove(userID) + } + + mockAPI := &MockAPI{ + GetTeamsForUserFunc: func(id string) ([]*model.Team, *model.AppError) { + return nil, nil + }, + GetUserByUsernameFunc: func(username string) (*model.User, *model.AppError) { + if username == "admin" { + return &model.User{Id: model.NewId(), Roles: "system_admin"}, nil + } + return nil, model.NewAppError("GetUserByUsername", "user not found", nil, "", 404) + }, + UpdateUserFunc: func(u *model.User) (*model.User, *model.AppError) { + return u, nil + }, + DeleteUserFunc: func(id string) *model.AppError { + if id == userID { + return model.NewAppError("DeleteUser", "delete failed", nil, "", 500) + } + return nil + }, + GetUserFunc: func(id string) (*model.User, *model.AppError) { + // This shouldn't be called since DeleteUser fails and we return early + return &model.User{Id: id}, nil + }, + } + p.SetAPI(mockAPI) + + err := p.cleanupUser(user) + assert.Error(t, err, "cleanupUser should return error when DeleteUser fails") + if err != nil { + assert.Contains(t, err.Error(), "unable to deactivate user") + } + }) + + t.Run("cleanupUser handles user not in teams gracefully", func(t *testing.T) { + userID := model.NewId() + user := &model.User{ + Id: userID, + Username: "baduser", + Email: "test@gooddomain.com", + } + + mockAPI := &MockAPI{ + GetTeamsForUserFunc: func(id string) ([]*model.Team, *model.AppError) { + return nil, nil // No teams + }, + GetUserByUsernameFunc: func(username string) (*model.User, *model.AppError) { + if username == "admin" { + return &model.User{Id: model.NewId(), Roles: "system_admin"}, nil + } + return nil, model.NewAppError("GetUserByUsername", "user not found", nil, "", 404) + }, + UpdateUserFunc: func(u *model.User) (*model.User, *model.AppError) { + return u, nil + }, + DeleteUserFunc: func(id string) *model.AppError { + return nil + }, + GetUserFunc: func(id string) (*model.User, *model.AppError) { + return &model.User{Id: id, DeleteAt: model.GetMillis()}, nil + }, + } + p.SetAPI(mockAPI) + + err := p.cleanupUser(user) + assert.NoError(t, err, "Should succeed even if user has no teams") + }) +} + +func TestShouldBlockUserPost(t *testing.T) { + badUsernamesRegex, _ := splitWordListToRegex("baduser", `(?mi)(%s)`) + badDomainsRegex, _ := splitWordListToRegex("baddomain.com", `(?mi)(%s)`) + + p := Plugin{ + configuration: &configuration{ + BadUsernamesList: "baduser", + BadDomainsList: "baddomain.com", + }, + cache: NewLRUCache(50), + badUsernamesRegex: badUsernamesRegex, + badDomainsRegex: badDomainsRegex, + } + + t.Run("shouldBlockUserPost blocks soft-deleted users", func(t *testing.T) { + userID := model.NewId() + deletedUser := &model.User{ + Id: userID, + Username: "gooduser", + DeleteAt: model.GetMillis(), // User is deleted + } + + mockAPI := &MockAPI{ + GetUserFunc: func(id string) (*model.User, *model.AppError) { + if id == userID { + return deletedUser, nil + } + return &model.User{Id: id}, nil + }, + } + p.SetAPI(mockAPI) + p.cache.Put(userID, deletedUser) + + shouldBlock := p.shouldBlockUserPost(userID) + assert.True(t, shouldBlock, "Should block soft-deleted user") + }) + + t.Run("shouldBlockUserPost blocks users with bad username", func(t *testing.T) { + userID := model.NewId() + badUser := &model.User{ + Id: userID, + Username: "baduser", + Email: "test@gooddomain.com", + } + + mockAPI := &MockAPI{ + GetUserFunc: func(id string) (*model.User, *model.AppError) { + if id == userID { + return badUser, nil + } + return &model.User{Id: id}, nil + }, + } + p.SetAPI(mockAPI) + p.cache.Put(userID, badUser) + + shouldBlock := p.shouldBlockUserPost(userID) + assert.True(t, shouldBlock, "Should block user with bad username") + }) + + t.Run("shouldBlockUserPost blocks users with bad email domain", func(t *testing.T) { + userID := model.NewId() + badUser := &model.User{ + Id: userID, + Username: "gooduser", + Email: "test@baddomain.com", + } + + mockAPI := &MockAPI{ + GetUserFunc: func(id string) (*model.User, *model.AppError) { + if id == userID { + return badUser, nil + } + return &model.User{Id: id}, nil + }, + } + p.SetAPI(mockAPI) + p.cache.Put(userID, badUser) + + shouldBlock := p.shouldBlockUserPost(userID) + assert.True(t, shouldBlock, "Should block user with bad email domain") + }) + + t.Run("shouldBlockUserPost allows good users", func(t *testing.T) { + userID := model.NewId() + goodUser := &model.User{ + Id: userID, + Username: "gooduser", + Email: "test@gooddomain.com", + } + + mockAPI := &MockAPI{ + GetUserFunc: func(id string) (*model.User, *model.AppError) { + if id == userID { + return goodUser, nil + } + return &model.User{Id: id}, nil + }, + } + p.SetAPI(mockAPI) + p.cache.Put(userID, goodUser) + + shouldBlock := p.shouldBlockUserPost(userID) + assert.False(t, shouldBlock, "Should not block good user") + }) +} + +func TestUserHasJoinedTeam(t *testing.T) { + badUsernamesRegex, _ := splitWordListToRegex("baduser", `(?mi)(%s)`) + + p := Plugin{ + configuration: &configuration{ + BadUsernamesList: "baduser", + }, + cache: NewLRUCache(50), + badUsernamesRegex: badUsernamesRegex, + } + + t.Run("removes user from team if user has bad username", func(t *testing.T) { + userID := model.NewId() + teamID := model.NewId() + adminID := model.NewId() + teamMember := &model.TeamMember{ + UserId: userID, + TeamId: teamID, + } + + // User with bad username + badUser := &model.User{ + Id: userID, + Username: "baduser", // Matches bad username pattern + DeleteAt: 0, + } + + removedFromTeam := false + mockAPI := &MockAPI{ + GetUserFunc: func(id string) (*model.User, *model.AppError) { + if id == userID { + return badUser, nil + } + return &model.User{Id: id}, nil + }, + GetUsersFunc: func(options *model.UserGetOptions) ([]*model.User, *model.AppError) { + if options != nil && options.Role == "system_admin" { + return []*model.User{ + {Id: adminID, Username: "admin", Roles: "system_admin", DeleteAt: 0}, + }, nil + } + return nil, nil + }, + DeleteTeamMemberFunc: func(teamIDParam, userIDParam, adminIDParam string) *model.AppError { + if teamIDParam == teamID && userIDParam == userID && adminIDParam == adminID { + removedFromTeam = true + } + return nil + }, + } + p.SetAPI(mockAPI) + p.cache.Put(userID, badUser) + + actor := &model.User{Id: model.NewId()} + p.UserHasJoinedTeam(&plugin.Context{}, teamMember, actor) + + assert.True(t, removedFromTeam, "User should be removed from team when they have bad username") + }) + + t.Run("removes user from team if user is soft-deleted", func(t *testing.T) { + userID := model.NewId() + teamID := model.NewId() + adminID := model.NewId() + teamMember := &model.TeamMember{ + UserId: userID, + TeamId: teamID, + } + + // User is soft-deleted + deletedUser := &model.User{ + Id: userID, + Username: "deleteduser", + DeleteAt: model.GetMillis(), // User is soft-deleted + } + + removedFromTeam := false + mockAPI := &MockAPI{ + GetUserFunc: func(id string) (*model.User, *model.AppError) { + if id == userID { + return deletedUser, nil + } + return &model.User{Id: id}, nil + }, + GetUsersFunc: func(options *model.UserGetOptions) ([]*model.User, *model.AppError) { + if options != nil && options.Role == "system_admin" { + return []*model.User{ + {Id: adminID, Username: "admin", Roles: "system_admin", DeleteAt: 0}, + }, nil + } + return nil, nil + }, + DeleteTeamMemberFunc: func(teamIDParam, userIDParam, adminIDParam string) *model.AppError { + if teamIDParam == teamID && userIDParam == userID && adminIDParam == adminID { + removedFromTeam = true + } + return nil + }, + } + p.SetAPI(mockAPI) + p.cache.Put(userID, deletedUser) + + actor := &model.User{Id: model.NewId()} + p.UserHasJoinedTeam(&plugin.Context{}, teamMember, actor) + + assert.True(t, removedFromTeam, "User should be removed from team when they are soft-deleted") + }) + + t.Run("does not remove user from team if user is valid", func(t *testing.T) { + userID := model.NewId() + teamID := model.NewId() + teamMember := &model.TeamMember{ + UserId: userID, + TeamId: teamID, + } + + // User is valid (not deleted, not bad username) + goodUser := &model.User{ + Id: userID, + Username: "gooduser", + Email: "good@example.com", + DeleteAt: 0, + } + + removedFromTeam := false + mockAPI := &MockAPI{ + GetUserFunc: func(id string) (*model.User, *model.AppError) { + if id == userID { + return goodUser, nil + } + return &model.User{Id: id}, nil + }, + DeleteTeamMemberFunc: func(teamIDParam, userIDParam, adminIDParam string) *model.AppError { + removedFromTeam = true + return nil + }, + } + p.SetAPI(mockAPI) + p.cache.Put(userID, goodUser) + + actor := &model.User{Id: model.NewId()} + p.UserHasJoinedTeam(&plugin.Context{}, teamMember, actor) + + assert.False(t, removedFromTeam, "User should NOT be removed from team when they are valid") + }) + + t.Run("handles admin lookup failure gracefully", func(t *testing.T) { + userID := model.NewId() + teamID := model.NewId() + teamMember := &model.TeamMember{ + UserId: userID, + TeamId: teamID, + } + + // User with bad username + badUser := &model.User{ + Id: userID, + Username: "baduser", // Matches bad username pattern + DeleteAt: 0, + } + + directRemovalAttempted := false + mockAPI := &MockAPI{ + GetUserFunc: func(id string) (*model.User, *model.AppError) { + if id == userID { + return badUser, nil + } + return &model.User{Id: id}, nil + }, + GetUsersFunc: func(options *model.UserGetOptions) ([]*model.User, *model.AppError) { + // No admin found - simulate failure + return nil, model.NewAppError("GetUsers", "no system admin users found", nil, "", 404) + }, + DeleteTeamMemberFunc: func(teamIDParam, userIDParam, adminIDParam string) *model.AppError { + // Should be called with empty admin ID as fallback + if adminIDParam == "" { + directRemovalAttempted = true + return nil // Simulate success + } + return model.NewAppError("DeleteTeamMember", "should use empty admin ID", nil, "", 500) + }, + } + p.SetAPI(mockAPI) + p.cache.Put(userID, badUser) + + actor := &model.User{Id: model.NewId()} + // Should not panic - should handle gracefully + p.UserHasJoinedTeam(&plugin.Context{}, teamMember, actor) + + // Test passes if no panic occurs and direct removal was attempted + assert.True(t, directRemovalAttempted, "Should attempt direct removal when admin lookup fails") + }) + + t.Run("handles DeleteTeamMember failure gracefully", func(t *testing.T) { + userID := model.NewId() + teamID := model.NewId() + adminID := model.NewId() + teamMember := &model.TeamMember{ + UserId: userID, + TeamId: teamID, + } + + // User with bad username + badUser := &model.User{ + Id: userID, + Username: "baduser", // Matches bad username pattern + DeleteAt: 0, + } + + mockAPI := &MockAPI{ + GetUserFunc: func(id string) (*model.User, *model.AppError) { + if id == userID { + return badUser, nil + } + return &model.User{Id: id}, nil + }, + GetUserByUsernameFunc: func(username string) (*model.User, *model.AppError) { + if username == "admin" { + return &model.User{Id: adminID, Username: "admin", Roles: "system_admin"}, nil + } + return nil, model.NewAppError("GetUserByUsername", "user not found", nil, "", 404) + }, + DeleteTeamMemberFunc: func(teamIDParam, userIDParam, adminIDParam string) *model.AppError { + // Simulate failure to remove team member + return model.NewAppError("DeleteTeamMember", "failed to remove team member", nil, "", 500) + }, + } + p.SetAPI(mockAPI) + p.cache.Put(userID, badUser) + + actor := &model.User{Id: model.NewId()} + // Should not panic - should handle gracefully + p.UserHasJoinedTeam(&plugin.Context{}, teamMember, actor) + + // Test passes if no panic occurs + assert.True(t, true, "Should handle DeleteTeamMember failure gracefully") + }) +} + +func TestBlockedUserPosts(t *testing.T) { + // Set up plugin with bad usernames and domains configured + p := Plugin{ + configuration: &configuration{ + CensorCharacter: "*", + RejectPosts: false, + BadWordsList: "def ghi,abc", + BadDomainsList: "baddomain.com,bad.org", + BadUsernamesList: "hate,sucks", + ExcludeBots: false, + BuiltinBadDomains: false, + }, + } + p.badWordsRegex = regexp.MustCompile(wordListToRegex(p.getConfiguration().BadWordsList, defaultRegexTemplate)) + p.badDomainsRegex = regexp.MustCompile(wordListToRegex(p.getConfiguration().BadDomainsList, `(?mi)(%s)`)) + p.badUsernamesRegex = regexp.MustCompile(wordListToRegex(p.getConfiguration().BadUsernamesList, `(?mi)(%s)`)) + p.cache = NewLRUCache(50) + + // Setup bad domains list (empty since BuiltinBadDomains is false) + emptyList := []string{} + p.badDomainsList = &emptyList + + t.Run("post from user with bad username should be rejected", func(t *testing.T) { + userID := model.NewId() + badUser := &model.User{ + Id: userID, + Email: userID + "@gooddomain.com", + Username: "ihateneil", + Nickname: "Good Guy", + } + p.cache.Put(userID, badUser) + + mockAPI := &MockAPI{ + GetUserFunc: func(id string) (*model.User, *model.AppError) { + if id == userID { + return badUser, nil + } + return &model.User{Id: id}, nil + }, + } + p.SetAPI(mockAPI) + + post := &model.Post{ + UserId: userID, + Message: "This is a normal message", + } + + filteredPost, msg := p.MessageWillBePosted(&plugin.Context{}, post) + + assert.Nil(t, filteredPost, "Post from user with bad username should be rejected") + assert.Contains(t, msg, "flagged for moderation") + }) + + t.Run("post from user with bad email domain should be rejected", func(t *testing.T) { + userID := model.NewId() + badUser := &model.User{ + Id: userID, + Email: userID + "@baddomain.com", + Username: "gooduser", + Nickname: "Good Guy", + } + p.cache.Put(userID, badUser) + + mockAPI := &MockAPI{ + GetUserFunc: func(id string) (*model.User, *model.AppError) { + if id == userID { + return badUser, nil + } + return &model.User{Id: id}, nil + }, + } + p.SetAPI(mockAPI) + + post := &model.Post{ + UserId: userID, + Message: "This is a normal message", + } + + filteredPost, msg := p.MessageWillBePosted(&plugin.Context{}, post) + + assert.Nil(t, filteredPost, "Post from user with bad email domain should be rejected") + assert.Contains(t, msg, "flagged for moderation") + }) + + t.Run("post from user with bad nickname should be rejected", func(t *testing.T) { + userID := model.NewId() + badUser := &model.User{ + Id: userID, + Email: userID + "@gooddomain.com", + Username: "gooduser", + Nickname: "Neil Sucks", + } + p.cache.Put(userID, badUser) + + mockAPI := &MockAPI{ + GetUserFunc: func(id string) (*model.User, *model.AppError) { + if id == userID { + return badUser, nil + } + return &model.User{Id: id}, nil + }, + } + p.SetAPI(mockAPI) + + post := &model.Post{ + UserId: userID, + Message: "This is a normal message", + } + + filteredPost, msg := p.MessageWillBePosted(&plugin.Context{}, post) + + assert.Nil(t, filteredPost, "Post from user with bad nickname should be rejected") + assert.Contains(t, msg, "flagged for moderation") + }) + + t.Run("post from valid user should pass through", func(t *testing.T) { + userID := model.NewId() + goodUser := &model.User{ + Id: userID, + Email: userID + "@gooddomain.com", + Username: "gooduser", + Nickname: "Good Guy", + } + p.cache.Put(userID, goodUser) + + mockAPI := &MockAPI{ + GetUserFunc: func(id string) (*model.User, *model.AppError) { + if id == userID { + return goodUser, nil + } + return &model.User{Id: id}, nil + }, + } + p.SetAPI(mockAPI) + + post := &model.Post{ + UserId: userID, + Message: "This is a normal message", + } + + filteredPost, msg := p.MessageWillBePosted(&plugin.Context{}, post) + + assert.NotNil(t, filteredPost, "Post from valid user should pass through") + assert.Empty(t, msg, "Valid user post should not have rejection message") + }) + + t.Run("post from bot should be allowed even if username is bad", func(t *testing.T) { + userID := model.NewId() + badUser := &model.User{ + Id: userID, + Email: userID + "@gooddomain.com", + Username: "ihateneil", + Nickname: "Bad Bot", + } + p.cache.Put(userID, badUser) + + mockAPI := &MockAPI{ + GetUserFunc: func(id string) (*model.User, *model.AppError) { + if id == userID { + return badUser, nil + } + return &model.User{Id: id}, nil + }, + } + p.SetAPI(mockAPI) + + post := &model.Post{ + UserId: userID, + Message: "This is a bot message", + } + post.AddProp("from_bot", "true") + + // Configure plugin to exclude bots + p.configuration.ExcludeBots = true + + filteredPost, msg := p.MessageWillBePosted(&plugin.Context{}, post) + + assert.NotNil(t, filteredPost, "Post from bot should be allowed even with bad username") + assert.Empty(t, msg, "Bot post should not have rejection message") + }) +} + +// TestFilterNewUserLinks verifies the link blocking functionality for new users +func TestFilterNewUserLinks(t *testing.T) { + now := time.Now() + newUserCreateAt := now.Unix() * 1000 // Created now + oldUserCreateAt := now.Add(-48*time.Hour).Unix() * 1000 // Created 48 hours ago + + mockAPI := &MockAPI{ + UpdateUserFunc: func(user *model.User) (*model.User, *model.AppError) { + return user, nil + }, + } + + t.Run("new user with link is blocked", func(t *testing.T) { + p := Plugin{ + configuration: &configuration{ + BlockNewUserLinks: true, + BlockNewUserLinksTime: "24h", + }, + } + p.SetAPI(mockAPI) + + // Create cache and add new user + p.cache = NewLRUCache(50) + newUser := &model.User{ + Id: model.NewId(), + CreateAt: newUserCreateAt, + } + p.cache.Put(newUser.Id, newUser) + + post := &model.Post{ + UserId: newUser.Id, + Message: "Check out https://example.com", + } + + filteredPost, msg := p.FilterNewUserLinks(p.getConfiguration(), post) + + assert.Nil(t, filteredPost, "Post with link from new user should be blocked") + assert.Contains(t, msg, "not allowed") + assert.Contains(t, msg, "links") + }) + + t.Run("old user with link is allowed", func(t *testing.T) { + p := Plugin{ + configuration: &configuration{ + BlockNewUserLinks: true, + BlockNewUserLinksTime: "24h", + }, + } + p.SetAPI(mockAPI) + + // Create cache and add old user + p.cache = NewLRUCache(50) + oldUser := &model.User{ + Id: model.NewId(), + CreateAt: oldUserCreateAt, + } + p.cache.Put(oldUser.Id, oldUser) + + post := &model.Post{ + UserId: oldUser.Id, + Message: "Check out https://example.com", + } + + filteredPost, msg := p.FilterNewUserLinks(p.getConfiguration(), post) + + assert.NotNil(t, filteredPost, "Post with link from old user should be allowed") + assert.Empty(t, msg) + }) + + t.Run("indefinite blocking with -1 duration", func(t *testing.T) { + p := Plugin{ + configuration: &configuration{ + BlockNewUserLinks: true, + BlockNewUserLinksTime: "-1", + }, + } + p.SetAPI(mockAPI) + + // Create cache and add user (even old users should be blocked) + p.cache = NewLRUCache(50) + oldUser := &model.User{ + Id: model.NewId(), + CreateAt: oldUserCreateAt, + } + p.cache.Put(oldUser.Id, oldUser) + + post := &model.Post{ + UserId: oldUser.Id, + Message: "Visit www.example.com", + } + + filteredPost, msg := p.FilterNewUserLinks(p.getConfiguration(), post) + + assert.Nil(t, filteredPost, "Post with link should be blocked with indefinite duration") + assert.Contains(t, msg, "indefinitely") + }) +} + +// TestFilterNewUserImages verifies the image blocking functionality for new users +func TestFilterNewUserImages(t *testing.T) { + now := time.Now() + newUserCreateAt := now.Unix() * 1000 // Created now + oldUserCreateAt := now.Add(-48*time.Hour).Unix() * 1000 // Created 48 hours ago + + mockAPI := &MockAPI{ + UpdateUserFunc: func(user *model.User) (*model.User, *model.AppError) { + return user, nil + }, + } + + t.Run("new user with image is blocked", func(t *testing.T) { + p := Plugin{ + configuration: &configuration{ + BlockNewUserImages: true, + BlockNewUserImagesTime: "24h", + }, + } + p.SetAPI(mockAPI) + + // Create cache and add new user + p.cache = NewLRUCache(50) + newUser := &model.User{ + Id: model.NewId(), + CreateAt: newUserCreateAt, + } + p.cache.Put(newUser.Id, newUser) + + post := &model.Post{ + UserId: newUser.Id, + Message: "![image](https://example.com/image.png)", + } + + filteredPost, msg := p.FilterNewUserImages(p.getConfiguration(), post) + + assert.Nil(t, filteredPost, "Post with image from new user should be blocked") + assert.Contains(t, msg, "not allowed") + assert.Contains(t, msg, "images") + }) + + t.Run("old user with image is allowed", func(t *testing.T) { + p := Plugin{ + configuration: &configuration{ + BlockNewUserImages: true, + BlockNewUserImagesTime: "24h", + }, + } + p.SetAPI(mockAPI) + + // Create cache and add old user + p.cache = NewLRUCache(50) + oldUser := &model.User{ + Id: model.NewId(), + CreateAt: oldUserCreateAt, + } + p.cache.Put(oldUser.Id, oldUser) + + post := &model.Post{ + UserId: oldUser.Id, + Message: "Here's a photo", + Metadata: &model.PostMetadata{ + Files: []*model.FileInfo{ + { + Extension: ".jpg", + Name: "photo.jpg", + }, + }, + }, + } + + filteredPost, msg := p.FilterNewUserImages(p.getConfiguration(), post) + + assert.NotNil(t, filteredPost, "Post with image from old user should be allowed") + assert.Empty(t, msg) + }) + + t.Run("indefinite blocking with -1 duration", func(t *testing.T) { + p := Plugin{ + configuration: &configuration{ + BlockNewUserImages: true, + BlockNewUserImagesTime: "-1", + }, + } + p.SetAPI(mockAPI) + + // Create cache and add user (even old users should be blocked) + p.cache = NewLRUCache(50) + oldUser := &model.User{ + Id: model.NewId(), + CreateAt: oldUserCreateAt, + } + p.cache.Put(oldUser.Id, oldUser) + + post := &model.Post{ + UserId: oldUser.Id, + Message: "Photo attached", + Metadata: &model.PostMetadata{ + Images: map[string]*model.PostImage{ + "https://example.com/image.jpg": {}, + }, + }, + } + + filteredPost, msg := p.FilterNewUserImages(p.getConfiguration(), post) + + assert.Nil(t, filteredPost, "Post with image should be blocked with indefinite duration") + assert.Contains(t, msg, "indefinitely") + }) + + t.Run("new user with multiple images is blocked", func(t *testing.T) { + p := Plugin{ + configuration: &configuration{ + BlockNewUserImages: true, + BlockNewUserImagesTime: "24h", + }, + } + p.SetAPI(mockAPI) + + // Create cache and add new user + p.cache = NewLRUCache(50) + newUser := &model.User{ + Id: model.NewId(), + CreateAt: newUserCreateAt, + } + p.cache.Put(newUser.Id, newUser) + + post := &model.Post{ + UserId: newUser.Id, + Message: "Multiple photos", + Metadata: &model.PostMetadata{ + Files: []*model.FileInfo{ + { + Extension: ".jpg", + Name: "photo1.jpg", + }, + { + Extension: ".png", + Name: "photo2.png", + }, + }, + }, + } + + filteredPost, msg := p.FilterNewUserImages(p.getConfiguration(), post) + + assert.Nil(t, filteredPost, "Post with multiple images from new user should be blocked") + assert.Contains(t, msg, "not allowed") + }) +} + +// Extended MockAPI for FilterDirectMessage tests +type ExtendedMockAPI struct { + MockAPI + GetUserFunc func(userID string) (*model.User, *model.AppError) + GetChannelFunc func(channelID string) (*model.Channel, *model.AppError) + SendEphemeralPostFunc func(userID string, post *model.Post) *model.Post +} + +func (m *ExtendedMockAPI) GetUser(userID string) (*model.User, *model.AppError) { + if m.GetUserFunc != nil { + return m.GetUserFunc(userID) + } + return &model.User{Id: userID}, nil +} + +func (m *ExtendedMockAPI) GetChannel(channelID string) (*model.Channel, *model.AppError) { + if m.GetChannelFunc != nil { + return m.GetChannelFunc(channelID) + } + return &model.Channel{Id: channelID, Type: model.ChannelTypeDirect}, nil +} + +func (m *ExtendedMockAPI) SendEphemeralPost(userID string, post *model.Post) *model.Post { + if m.SendEphemeralPostFunc != nil { + return m.SendEphemeralPostFunc(userID, post) + } + return post +} + +// TestFilterDirectMessageIndefiniteDuration verifies the refactored DM blocking works with indefinite duration +func TestFilterDirectMessageIndefiniteDuration(t *testing.T) { + now := time.Now() + newUserCreateAt := now.Unix() * 1000 // Created now + oldUserCreateAt := now.Add(-48*time.Hour).Unix() * 1000 // Created 48 hours ago + + mockAPI := &MockAPI{ + UpdateUserFunc: func(user *model.User) (*model.User, *model.AppError) { + return user, nil + }, + } + + t.Run("indefinite DM blocking with -1 duration", func(t *testing.T) { + p := Plugin{ + configuration: &configuration{ + BlockNewUserPM: true, + BlockNewUserPMTime: "-1", + }, + } + p.SetAPI(mockAPI) + + // Create cache and add user (even old users should be blocked) + p.cache = NewLRUCache(50) + oldUser := &model.User{ + Id: model.NewId(), + CreateAt: oldUserCreateAt, + } + p.cache.Put(oldUser.Id, oldUser) + + post := &model.Post{ + UserId: oldUser.Id, + Message: "Direct message to another user", + } + + filteredPost, msg := p.FilterDirectMessage(p.getConfiguration(), post) + + assert.Nil(t, filteredPost, "DM should be blocked with indefinite duration") + assert.Contains(t, msg, "indefinitely") + }) + + t.Run("standard duration still works", func(t *testing.T) { + p := Plugin{ + configuration: &configuration{ + BlockNewUserPM: true, + BlockNewUserPMTime: "24h", + }, + } + p.SetAPI(mockAPI) + + // Create cache and add new user + p.cache = NewLRUCache(50) + newUser := &model.User{ + Id: model.NewId(), + CreateAt: newUserCreateAt, + } + p.cache.Put(newUser.Id, newUser) + + post := &model.Post{ + UserId: newUser.Id, + Message: "Direct message", + } + + filteredPost, msg := p.FilterDirectMessage(p.getConfiguration(), post) + + assert.Nil(t, filteredPost, "DM from new user should be blocked with standard duration") + assert.Contains(t, msg, "not allowed") + }) + + t.Run("old user with standard duration is allowed", func(t *testing.T) { + p := Plugin{ + configuration: &configuration{ + BlockNewUserPM: true, + BlockNewUserPMTime: "24h", + }, + } + p.SetAPI(mockAPI) + + // Create cache and add old user + p.cache = NewLRUCache(50) + oldUser := &model.User{ + Id: model.NewId(), + CreateAt: oldUserCreateAt, + } + p.cache.Put(oldUser.Id, oldUser) + + post := &model.Post{ + UserId: oldUser.Id, + Message: "Direct message", + } + + filteredPost, msg := p.FilterDirectMessage(p.getConfiguration(), post) + + assert.NotNil(t, filteredPost, "DM from old user should be allowed") + assert.Empty(t, msg) + }) +} + +func TestFilterDirectMessage(t *testing.T) { + t.Run("blocks new user within time restriction", func(t *testing.T) { + p := Plugin{ + configuration: &configuration{ + BlockNewUserPM: true, + BlockNewUserPMTime: "24h", + }, + cache: NewLRUCache(10), + } + + // User created 1 hour ago + oneHourAgo := model.GetMillis() - (60 * 60 * 1000) + testUser := &model.User{ + Id: "new-user", + CreateAt: oneHourAgo, + } + + ephemeralSent := false + p.SetAPI(&ExtendedMockAPI{ + GetUserFunc: func(userID string) (*model.User, *model.AppError) { + if userID == "new-user" { + return testUser, nil + } + return nil, model.NewAppError("GetUser", "user not found", nil, "", 404) + }, + SendEphemeralPostFunc: func(userID string, post *model.Post) *model.Post { + ephemeralSent = true + assert.Equal(t, "Configuration settings limit new users from sending private messages.", post.Message) + return post + }, + }) + + post := &model.Post{ + UserId: "new-user", + ChannelId: "dm-channel", + Message: "Hello", + } + + resultPost, rejectReason := p.FilterDirectMessage(p.configuration, post) + + assert.Nil(t, resultPost) + assert.Contains(t, rejectReason, "New user not allowed to post direct messages") + assert.True(t, ephemeralSent) + }) + + t.Run("allows user past time restriction", func(t *testing.T) { + p := Plugin{ + configuration: &configuration{ + BlockNewUserPM: true, + BlockNewUserPMTime: "24h", + }, + cache: NewLRUCache(10), + } + + // User created 25 hours ago + twentyFiveHoursAgo := model.GetMillis() - (25 * 60 * 60 * 1000) + testUser := &model.User{ + Id: "old-user", + CreateAt: twentyFiveHoursAgo, + } + + p.SetAPI(&ExtendedMockAPI{ + GetUserFunc: func(userID string) (*model.User, *model.AppError) { + if userID == "old-user" { + return testUser, nil + } + return nil, model.NewAppError("GetUser", "user not found", nil, "", 404) + }, + }) + + post := &model.Post{ + UserId: "old-user", + ChannelId: "dm-channel", + Message: "Hello", + } + + resultPost, rejectReason := p.FilterDirectMessage(p.configuration, post) + + assert.Equal(t, post, resultPost) + assert.Empty(t, rejectReason) + }) + + t.Run("handles invalid duration format", func(t *testing.T) { + p := Plugin{ + configuration: &configuration{ + BlockNewUserPM: true, + BlockNewUserPMTime: "invalid-duration", + }, + cache: NewLRUCache(10), + } + + testUser := &model.User{ + Id: "user", + CreateAt: model.GetMillis(), + } + + ephemeralSent := false + p.SetAPI(&ExtendedMockAPI{ + GetUserFunc: func(userID string) (*model.User, *model.AppError) { + return testUser, nil + }, + SendEphemeralPostFunc: func(userID string, post *model.Post) *model.Post { + ephemeralSent = true + assert.Equal(t, "Something went wrong when sending your message. Contact an administrator.", post.Message) + return post + }, + }) + + post := &model.Post{ + UserId: "user", + ChannelId: "dm-channel", + Message: "Hello", + } + + resultPost, rejectReason := p.FilterDirectMessage(p.configuration, post) + + assert.Nil(t, resultPost) + assert.Contains(t, rejectReason, "failed to parse duration") + assert.True(t, ephemeralSent) + }) + + t.Run("handles user not found error", func(t *testing.T) { + p := Plugin{ + configuration: &configuration{ + BlockNewUserPM: true, + BlockNewUserPMTime: "24h", + }, + cache: NewLRUCache(10), + } + + ephemeralSent := false + p.SetAPI(&ExtendedMockAPI{ GetUserFunc: func(userID string) (*model.User, *model.AppError) { return nil, model.NewAppError("GetUser", "user not found", nil, "", 404) }, @@ -560,8 +1962,119 @@ func TestFilterDirectMessage(t *testing.T) { assert.Equal(t, post, resultPost) assert.Empty(t, rejectReason) } - }) + }) + } + }) + + t.Run("allows system admin even if new user", func(t *testing.T) { + p := Plugin{ + configuration: &configuration{ + BlockNewUserPM: true, + BlockNewUserPMTime: "24h", + }, + cache: NewLRUCache(10), + } + + // Admin user created just now (should normally be blocked) + adminUser := &model.User{ + Id: "admin-user", + CreateAt: model.GetMillis(), + Roles: "system_admin", + } + + p.SetAPI(&ExtendedMockAPI{ + GetUserFunc: func(userID string) (*model.User, *model.AppError) { + if userID == "admin-user" { + return adminUser, nil + } + return nil, model.NewAppError("GetUser", "user not found", nil, "", 404) + }, + }) + + post := &model.Post{ + UserId: "admin-user", + ChannelId: "dm-channel", + Message: "Hello from admin", + } + + resultPost, rejectReason := p.FilterDirectMessage(p.configuration, post) + + assert.Equal(t, post, resultPost, "Admin user should be allowed to send DM") + assert.Empty(t, rejectReason, "Admin user should not be rejected") + }) + + t.Run("allows system admin even with indefinite blocking", func(t *testing.T) { + p := Plugin{ + configuration: &configuration{ + BlockNewUserPM: true, + BlockNewUserPMTime: "-1", // Indefinite blocking + }, + cache: NewLRUCache(10), + } + + // Admin user created just now (should normally be blocked indefinitely) + adminUser := &model.User{ + Id: "admin-user", + CreateAt: model.GetMillis(), + Roles: "system_admin", + } + + p.SetAPI(&ExtendedMockAPI{ + GetUserFunc: func(userID string) (*model.User, *model.AppError) { + if userID == "admin-user" { + return adminUser, nil + } + return nil, model.NewAppError("GetUser", "user not found", nil, "", 404) + }, + }) + + post := &model.Post{ + UserId: "admin-user", + ChannelId: "dm-channel", + Message: "Hello from admin", + } + + resultPost, rejectReason := p.FilterDirectMessage(p.configuration, post) + + assert.Equal(t, post, resultPost, "Admin user should be allowed to send DM even with indefinite blocking") + assert.Empty(t, rejectReason, "Admin user should not be rejected") + }) + + t.Run("allows system admin with multiple roles", func(t *testing.T) { + p := Plugin{ + configuration: &configuration{ + BlockNewUserPM: true, + BlockNewUserPMTime: "24h", + }, + cache: NewLRUCache(10), + } + + // Admin user with multiple roles (system_admin is one of them) + adminUser := &model.User{ + Id: "admin-user", + CreateAt: model.GetMillis(), + Roles: "system_admin system_user", + } + + p.SetAPI(&ExtendedMockAPI{ + GetUserFunc: func(userID string) (*model.User, *model.AppError) { + if userID == "admin-user" { + return adminUser, nil + } + return nil, model.NewAppError("GetUser", "user not found", nil, "", 404) + }, + }) + + post := &model.Post{ + UserId: "admin-user", + ChannelId: "dm-channel", + Message: "Hello from admin", } + + resultPost, rejectReason := p.FilterDirectMessage(p.configuration, post) + + assert.Equal(t, post, resultPost, "Admin user with multiple roles should be allowed to send DM") + assert.Empty(t, rejectReason, "Admin user should not be rejected") }) } @@ -698,3 +2211,564 @@ func TestGetUserByID(t *testing.T) { assert.True(t, found) }) } + +// TestFilterPostIntegration verifies the full FilterPost flow with image blocking +// This test ensures the bug where images were checked after links is fixed +func TestFilterPostIntegration(t *testing.T) { + now := time.Now() + newUserCreateAt := now.Unix() * 1000 // Created now + oldUserCreateAt := now.Add(-48*time.Hour).Unix() * 1000 // Created 48 hours ago + + t.Run("new user posting image URL is blocked as image, not link", func(t *testing.T) { + p := Plugin{ + configuration: &configuration{ + BlockNewUserImages: true, + BlockNewUserImagesTime: "24h", + BlockNewUserLinks: true, + BlockNewUserLinksTime: "24h", + }, + } + + // Create cache and add new user + p.cache = NewLRUCache(50) + newUser := &model.User{ + Id: model.NewId(), + CreateAt: newUserCreateAt, + } + p.cache.Put(newUser.Id, newUser) + + mockAPI := &ExtendedMockAPI{ + MockAPI: MockAPI{}, + GetUserFunc: func(userID string) (*model.User, *model.AppError) { + if user, found := p.cache.Get(userID); found { + return user, nil + } + return nil, &model.AppError{Message: "user not found"} + }, + SendEphemeralPostFunc: func(userID string, post *model.Post) *model.Post { + return post + }, + } + p.SetAPI(mockAPI) + + // Post with image URL (contains both link and image - should be blocked as image) + post := &model.Post{ + UserId: newUser.Id, + Message: "![image](https://example.com/image.png)", + } + + filteredPost, msg := p.FilterPost(post) + + // Should be blocked as image, not link + assert.Nil(t, filteredPost, "Post with image URL from new user should be blocked") + assert.Contains(t, msg, "images", "Should be blocked as image, not link") + assert.NotContains(t, msg, "links", "Should not be blocked as link") + }) + + t.Run("new user posting image file attachment is blocked", func(t *testing.T) { + p := Plugin{ + configuration: &configuration{ + BlockNewUserImages: true, + BlockNewUserImagesTime: "24h", + }, + } + + // Create cache and add new user + p.cache = NewLRUCache(50) + newUser := &model.User{ + Id: model.NewId(), + CreateAt: newUserCreateAt, + } + p.cache.Put(newUser.Id, newUser) + + mockAPI := &ExtendedMockAPI{ + MockAPI: MockAPI{}, + GetUserFunc: func(userID string) (*model.User, *model.AppError) { + if user, found := p.cache.Get(userID); found { + return user, nil + } + return nil, &model.AppError{Message: "user not found"} + }, + SendEphemeralPostFunc: func(userID string, post *model.Post) *model.Post { + return post + }, + } + p.SetAPI(mockAPI) + + post := &model.Post{ + UserId: newUser.Id, + Message: "Here's a photo", + Metadata: &model.PostMetadata{ + Files: []*model.FileInfo{ + { + Extension: ".jpg", + Name: "photo.jpg", + }, + }, + }, + } + + filteredPost, msg := p.FilterPost(post) + + assert.Nil(t, filteredPost, "Post with image file from new user should be blocked") + assert.Contains(t, msg, "images", "Should mention images in error message") + }) + + t.Run("old user posting image URL is allowed", func(t *testing.T) { + p := Plugin{ + configuration: &configuration{ + BlockNewUserImages: true, + BlockNewUserImagesTime: "24h", + BlockNewUserLinks: true, + BlockNewUserLinksTime: "24h", + }, + } + + // Create cache and add old user + p.cache = NewLRUCache(50) + oldUser := &model.User{ + Id: model.NewId(), + CreateAt: oldUserCreateAt, + } + p.cache.Put(oldUser.Id, oldUser) + + mockAPI := &ExtendedMockAPI{ + MockAPI: MockAPI{}, + GetUserFunc: func(userID string) (*model.User, *model.AppError) { + if user, found := p.cache.Get(userID); found { + return user, nil + } + return nil, &model.AppError{Message: "user not found"} + }, + SendEphemeralPostFunc: func(userID string, post *model.Post) *model.Post { + return post + }, + } + p.SetAPI(mockAPI) + + post := &model.Post{ + UserId: oldUser.Id, + Message: "![image](https://example.com/image.png)", + } + + filteredPost, msg := p.FilterPost(post) + + // Should pass through (no blocking) + assert.NotNil(t, filteredPost, "Post with image from old user should be allowed") + assert.Empty(t, msg, "Should not have error message for old user") + }) + + t.Run("new user posting link without image is blocked as link", func(t *testing.T) { + p := Plugin{ + configuration: &configuration{ + BlockNewUserImages: true, + BlockNewUserImagesTime: "24h", + BlockNewUserLinks: true, + BlockNewUserLinksTime: "24h", + }, + } + + // Create cache and add new user + p.cache = NewLRUCache(50) + newUser := &model.User{ + Id: model.NewId(), + CreateAt: newUserCreateAt, + } + p.cache.Put(newUser.Id, newUser) + + mockAPI := &ExtendedMockAPI{ + MockAPI: MockAPI{}, + GetUserFunc: func(userID string) (*model.User, *model.AppError) { + if user, found := p.cache.Get(userID); found { + return user, nil + } + return nil, &model.AppError{Message: "user not found"} + }, + SendEphemeralPostFunc: func(userID string, post *model.Post) *model.Post { + return post + }, + } + p.SetAPI(mockAPI) + + post := &model.Post{ + UserId: newUser.Id, + Message: "Check out https://example.com for more info", + } + + filteredPost, msg := p.FilterPost(post) + + // Should be blocked as link (not image since it's not an image) + assert.Nil(t, filteredPost, "Post with link from new user should be blocked") + assert.Contains(t, msg, "links", "Should be blocked as link") + assert.NotContains(t, msg, "images", "Should not mention images") + }) + + t.Run("new user posting image with metadata.Images is blocked", func(t *testing.T) { + p := Plugin{ + configuration: &configuration{ + BlockNewUserImages: true, + BlockNewUserImagesTime: "24h", + }, + } + + // Create cache and add new user + p.cache = NewLRUCache(50) + newUser := &model.User{ + Id: model.NewId(), + CreateAt: newUserCreateAt, + } + p.cache.Put(newUser.Id, newUser) + + mockAPI := &ExtendedMockAPI{ + MockAPI: MockAPI{}, + GetUserFunc: func(userID string) (*model.User, *model.AppError) { + if user, found := p.cache.Get(userID); found { + return user, nil + } + return nil, &model.AppError{Message: "user not found"} + }, + SendEphemeralPostFunc: func(userID string, post *model.Post) *model.Post { + return post + }, + } + p.SetAPI(mockAPI) + + post := &model.Post{ + UserId: newUser.Id, + Message: "https://example.com/image.jpg", + Metadata: &model.PostMetadata{ + Images: map[string]*model.PostImage{ + "https://example.com/image.jpg": {}, + }, + }, + } + + filteredPost, msg := p.FilterPost(post) + + assert.Nil(t, filteredPost, "Post with image in metadata from new user should be blocked") + assert.Contains(t, msg, "images", "Should mention images in error message") + }) + + t.Run("new user posting image using FileIds is blocked", func(t *testing.T) { + p := Plugin{ + configuration: &configuration{ + BlockNewUserImages: true, + BlockNewUserImagesTime: "24h", + }, + } + + // Create cache and add new user + p.cache = NewLRUCache(50) + newUser := &model.User{ + Id: model.NewId(), + CreateAt: newUserCreateAt, + } + p.cache.Put(newUser.Id, newUser) + + mockAPI := &ExtendedMockAPI{ + MockAPI: MockAPI{ + GetFileInfoFunc: func(fileID string) (*model.FileInfo, *model.AppError) { + // Return image file info for any file ID + return &model.FileInfo{ + Id: fileID, + Extension: ".jpg", + Name: "photo.jpg", + }, nil + }, + }, + GetUserFunc: func(userID string) (*model.User, *model.AppError) { + if user, found := p.cache.Get(userID); found { + return user, nil + } + return nil, &model.AppError{Message: "user not found"} + }, + SendEphemeralPostFunc: func(userID string, post *model.Post) *model.Post { + return post + }, + } + p.SetAPI(mockAPI) + + // Post with FileIds but no Metadata.Files (simulates real upload scenario) + post := &model.Post{ + UserId: newUser.Id, + Message: "Here's a photo", + FileIds: []string{"file123"}, + // Metadata.Files is nil/empty - this is what happens during MessageWillBePosted + } + + filteredPost, msg := p.FilterPost(post) + + assert.Nil(t, filteredPost, "Post with image FileIds from new user should be blocked") + assert.Contains(t, msg, "images", "Should mention images in error message") + }) + + t.Run("image blocking disabled allows new users to post images", func(t *testing.T) { + p := Plugin{ + configuration: &configuration{ + BlockNewUserImages: false, + BlockNewUserImagesTime: "24h", + BadWordsList: "", // Empty to avoid bad word filtering + }, + } + // Initialize badWordsRegex to avoid nil pointer dereference + p.badWordsRegex = regexp.MustCompile(wordListToRegex(p.getConfiguration().BadWordsList, defaultRegexTemplate)) + + // Create cache and add new user + p.cache = NewLRUCache(50) + newUser := &model.User{ + Id: model.NewId(), + CreateAt: newUserCreateAt, + } + p.cache.Put(newUser.Id, newUser) + + mockAPI := &ExtendedMockAPI{ + MockAPI: MockAPI{}, + GetUserFunc: func(userID string) (*model.User, *model.AppError) { + if user, found := p.cache.Get(userID); found { + return user, nil + } + return nil, &model.AppError{Message: "user not found"} + }, + } + p.SetAPI(mockAPI) + + post := &model.Post{ + UserId: newUser.Id, + Message: "![image](https://example.com/image.png)", + } + + filteredPost, msg := p.FilterPost(post) + + // Should pass through (blocking disabled) + assert.NotNil(t, filteredPost, "Post with image should be allowed when blocking is disabled") + assert.Empty(t, msg, "Should not have error message when blocking is disabled") + }) +} + +// TestUserWillLogIn verifies the security-critical login validation hook +func TestUserWillLogIn(t *testing.T) { + t.Run("allows valid user to login", func(t *testing.T) { + p := Plugin{ + configuration: &configuration{ + BadUsernamesList: "baduser", + BadDomainsList: "baddomain.com", + }, + } + p.badUsernamesRegex = regexp.MustCompile(wordListToRegex("baduser", `(?mi)(%s)`)) + p.badDomainsRegex = regexp.MustCompile(wordListToRegex("baddomain.com", defaultRegexTemplate)) + p.badDomainsList = &[]string{} + + mockAPI := &MockAPI{} + p.SetAPI(mockAPI) + + validUser := &model.User{ + Id: model.NewId(), + Username: "gooduser", + Email: "user@gooddomain.com", + DeleteAt: 0, + } + + errorMsg := p.UserWillLogIn(&plugin.Context{}, validUser) + + assert.Empty(t, errorMsg, "Valid user should be allowed to login") + }) + + t.Run("blocks soft-deleted user from login", func(t *testing.T) { + p := Plugin{ + configuration: &configuration{}, + } + + mockAPI := &MockAPI{} + p.SetAPI(mockAPI) + + deletedUser := &model.User{ + Id: model.NewId(), + Username: "deleteduser", + Email: "user@example.com", + DeleteAt: model.GetMillis(), // User is soft-deleted + } + + errorMsg := p.UserWillLogIn(&plugin.Context{}, deletedUser) + + assert.NotEmpty(t, errorMsg, "Soft-deleted user should be blocked from login") + assert.Contains(t, errorMsg, "deactivated", "Error message should mention deactivation") + assert.Contains(t, errorMsg, "policy violations", "Error message should mention policy violations") + }) + + t.Run("blocks user with bad username from login", func(t *testing.T) { + p := Plugin{ + configuration: &configuration{ + BadUsernamesList: "spammer,baduser", + }, + } + p.badUsernamesRegex = regexp.MustCompile(wordListToRegex("spammer,baduser", `(?mi)(%s)`)) + p.badDomainsList = &[]string{} + + mockAPI := &MockAPI{} + p.SetAPI(mockAPI) + + badUser := &model.User{ + Id: model.NewId(), + Username: "spammer123", + Email: "user@gooddomain.com", + DeleteAt: 0, + } + + errorMsg := p.UserWillLogIn(&plugin.Context{}, badUser) + + assert.NotEmpty(t, errorMsg, "User with bad username should be blocked from login") + assert.Contains(t, errorMsg, "community guidelines", "Error message should mention guidelines") + }) + + t.Run("blocks user with bad nickname from login", func(t *testing.T) { + p := Plugin{ + configuration: &configuration{ + BadUsernamesList: "spammer", + }, + } + p.badUsernamesRegex = regexp.MustCompile(wordListToRegex("spammer", `(?mi)(%s)`)) + p.badDomainsList = &[]string{} + + mockAPI := &MockAPI{} + p.SetAPI(mockAPI) + + badUser := &model.User{ + Id: model.NewId(), + Username: "goodusername", + Nickname: "I am a spammer", + Email: "user@gooddomain.com", + DeleteAt: 0, + } + + errorMsg := p.UserWillLogIn(&plugin.Context{}, badUser) + + assert.NotEmpty(t, errorMsg, "User with bad nickname should be blocked from login") + assert.Contains(t, errorMsg, "community guidelines", "Error message should mention guidelines") + }) + + t.Run("blocks user with bad email domain from login", func(t *testing.T) { + p := Plugin{ + configuration: &configuration{ + BadDomainsList: "tempmail.com,spam.com", + }, + } + p.badDomainsRegex = regexp.MustCompile(wordListToRegex("tempmail.com,spam.com", defaultRegexTemplate)) + p.badDomainsList = &[]string{} + + mockAPI := &MockAPI{} + p.SetAPI(mockAPI) + + badUser := &model.User{ + Id: model.NewId(), + Username: "gooduser", + Email: "user@tempmail.com", + DeleteAt: 0, + } + + errorMsg := p.UserWillLogIn(&plugin.Context{}, badUser) + + assert.NotEmpty(t, errorMsg, "User with bad email domain should be blocked from login") + assert.Contains(t, errorMsg, "community guidelines", "Error message should mention guidelines") + }) + + t.Run("blocks user with builtin bad domain from login", func(t *testing.T) { + builtinDomains := []string{"hoo.com", "disposable.email"} + + p := Plugin{ + configuration: &configuration{ + BuiltinBadDomains: true, + }, + badDomainsList: &builtinDomains, + } + + mockAPI := &MockAPI{} + p.SetAPI(mockAPI) + + badUser := &model.User{ + Id: model.NewId(), + Username: "gooduser", + Email: "user@hoo.com", + DeleteAt: 0, + } + + errorMsg := p.UserWillLogIn(&plugin.Context{}, badUser) + + assert.NotEmpty(t, errorMsg, "User with builtin bad domain should be blocked from login") + assert.Contains(t, errorMsg, "community guidelines", "Error message should mention guidelines") + }) + + t.Run("blocks soft-deleted user even with good credentials", func(t *testing.T) { + p := Plugin{ + configuration: &configuration{ + BadUsernamesList: "baduser", + }, + } + p.badUsernamesRegex = regexp.MustCompile(wordListToRegex("baduser", `(?mi)(%s)`)) + p.badDomainsList = &[]string{} + + mockAPI := &MockAPI{} + p.SetAPI(mockAPI) + + deletedUser := &model.User{ + Id: model.NewId(), + Username: "gooduser", // Good username + Email: "user@gooddomain.com", + DeleteAt: model.GetMillis(), // But user is deleted + } + + errorMsg := p.UserWillLogIn(&plugin.Context{}, deletedUser) + + // Soft-deleted check happens first, so it should block with deactivated message + assert.NotEmpty(t, errorMsg, "Soft-deleted user should be blocked even with good credentials") + assert.Contains(t, errorMsg, "deactivated", "Error message should mention deactivation") + }) + + t.Run("security: prevents deleted user from re-authenticating", func(t *testing.T) { + p := Plugin{ + configuration: &configuration{}, + } + + mockAPI := &MockAPI{} + p.SetAPI(mockAPI) + + // Simulate a user who was deleted due to bad username + // but is trying to login again (maybe they cached credentials) + deletedBadUser := &model.User{ + Id: model.NewId(), + Username: "sanitized-userid123", // Username was sanitized during cleanup + Email: "user@example.com", + DeleteAt: model.GetMillis(), // User was soft-deleted + } + + errorMsg := p.UserWillLogIn(&plugin.Context{}, deletedBadUser) + + assert.NotEmpty(t, errorMsg, "Deleted user should not be able to re-authenticate") + assert.Contains(t, errorMsg, "deactivated", "Should inform user account is deactivated") + }) + + t.Run("allows user with no validation rules configured", func(t *testing.T) { + p := Plugin{ + configuration: &configuration{ + // No bad words, domains, or usernames configured + }, + // Nil regexes mean no validation rules + badUsernamesRegex: nil, + badDomainsRegex: nil, + badDomainsList: &[]string{}, + } + + mockAPI := &MockAPI{} + p.SetAPI(mockAPI) + + user := &model.User{ + Id: model.NewId(), + Username: "anyuser", + Email: "user@anydomain.com", + DeleteAt: 0, + } + + errorMsg := p.UserWillLogIn(&plugin.Context{}, user) + + assert.Empty(t, errorMsg, "User should be allowed when no validation rules are configured") + }) +} diff --git a/server/user.go b/server/user.go index eaa49c0..0eb1290 100644 --- a/server/user.go +++ b/server/user.go @@ -1,64 +1,94 @@ package main import ( - "container/list" "sync" + "time" "github.com/mattermost/mattermost/server/public/model" ) type LRUCache struct { capacity int - lock sync.Mutex - cache map[string]*list.Element - lruList *list.List // List to maintain LRU order + lock sync.RWMutex + cache map[string]*cacheEntry } -type entry struct { - key string - user *model.User +type cacheEntry struct { + user *model.User + lastAccess time.Time } func NewLRUCache(capacity int) *LRUCache { return &LRUCache{ capacity: capacity, - cache: make(map[string]*list.Element), - lruList: list.New(), + cache: make(map[string]*cacheEntry), } } func (c *LRUCache) Get(key string) (*model.User, bool) { + // Fast path: RLock for lookup + c.lock.RLock() + entry, found := c.cache[key] + c.lock.RUnlock() + + if !found { + return &model.User{}, false + } + + // Update access time (lock-free for reads, will use mutex for accuracy) + // Using write lock briefly to update access time c.lock.Lock() defer c.lock.Unlock() + entry.lastAccess = time.Now() - if elem, found := c.cache[key]; found { - c.lruList.MoveToFront(elem) // Mark as most recently used - return elem.Value.(entry).user, true - } - return &model.User{}, false // Return zero value if not found + return entry.user, true } func (c *LRUCache) Put(key string, user *model.User) { c.lock.Lock() defer c.lock.Unlock() - // Update the value if it already exists and move it to the front - if elem, found := c.cache[key]; found { - c.lruList.MoveToFront(elem) - elem.Value = entry{key, user} + // Update the value if it already exists + if entry, found := c.cache[key]; found { + entry.user = user + entry.lastAccess = time.Now() return } // If the cache is at capacity, remove the least recently used item - if c.lruList.Len() == c.capacity { - oldest := c.lruList.Back() - if oldest != nil { - c.lruList.Remove(oldest) - delete(c.cache, oldest.Value.(entry).key) + if len(c.cache) >= c.capacity { + c.evictOldest() + } + + // Add the new item to the cache + c.cache[key] = &cacheEntry{ + user: user, + lastAccess: time.Now(), + } +} + +// evictOldest removes the least recently used item from the cache +// Must be called with lock held +func (c *LRUCache) evictOldest() { + var oldestKey string + var oldestTime time.Time + + for k, v := range c.cache { + if oldestKey == "" || v.lastAccess.Before(oldestTime) { + oldestKey = k + oldestTime = v.lastAccess } } - // Add the new item to the cache and LRU list - elem := c.lruList.PushFront(entry{key, user}) - c.cache[key] = elem + if oldestKey != "" { + delete(c.cache, oldestKey) + } +} + +// Remove removes an entry from the cache +func (c *LRUCache) Remove(key string) { + c.lock.Lock() + defer c.lock.Unlock() + + delete(c.cache, key) } diff --git a/server/user_test.go b/server/user_test.go index 06863dc..091296a 100644 --- a/server/user_test.go +++ b/server/user_test.go @@ -15,8 +15,7 @@ func TestNewLRUCache(t *testing.T) { require.NotNil(t, cache) assert.Equal(t, 50, cache.capacity) assert.NotNil(t, cache.cache) - assert.NotNil(t, cache.lruList) - assert.Equal(t, 0, cache.lruList.Len()) + assert.Equal(t, 0, len(cache.cache)) }) t.Run("creates cache with small capacity", func(t *testing.T) { @@ -222,8 +221,7 @@ func TestLRUCacheConcurrency(t *testing.T) { wg.Wait() // Verify cache state is consistent - assert.Equal(t, cache.lruList.Len(), 100) - assert.Equal(t, cache.lruList.Len(), len(cache.cache)) + assert.Equal(t, 100, len(cache.cache)) }) t.Run("handles mixed concurrent operations", func(t *testing.T) { @@ -260,8 +258,7 @@ func TestLRUCacheConcurrency(t *testing.T) { wg.Wait() // Verify cache state is consistent - assert.Equal(t, cache.lruList.Len(), 50) - assert.Equal(t, cache.lruList.Len(), len(cache.cache)) + assert.Equal(t, 50, len(cache.cache)) }) } @@ -315,7 +312,6 @@ func TestLRUCacheMemoryBehavior(t *testing.T) { } // Verify cache doesn't exceed capacity - assert.Equal(t, capacity, cache.lruList.Len()) assert.Equal(t, capacity, len(cache.cache)) }) } diff --git a/server/utils_test.go b/server/utils_test.go new file mode 100644 index 0000000..f5dd0ed --- /dev/null +++ b/server/utils_test.go @@ -0,0 +1,431 @@ +package main + +import ( + "testing" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/assert" +) + +func TestRemoveAccents(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "simple accented characters", + input: "café", + expected: "cafe", + }, + { + name: "multiple accents", + input: "âêîôû", + expected: "aeiou", + }, + { + name: "mixed accented text", + input: "Héllo Wörld", + expected: "Hello World", + }, + { + name: "profanity bypass attempt", + input: "bâd", + expected: "bad", + }, + { + name: "french accents", + input: "français", + expected: "francais", + }, + { + name: "spanish accents", + input: "niño señor", + expected: "nino senor", + }, + { + name: "german umlauts", + input: "Müller Schön", + expected: "Muller Schon", + }, + { + name: "no accents (ASCII only)", + input: "Hello World 123", + expected: "Hello World 123", + }, + { + name: "empty string", + input: "", + expected: "", + }, + { + name: "numbers and symbols unchanged", + input: "test@123!", + expected: "test@123!", + }, + { + name: "combining diacritics", + input: "e\u0301", // é composed as e + combining acute accent + expected: "e", + }, + { + name: "mixed languages", + input: "Café München São Paulo", + expected: "Cafe Munchen Sao Paulo", + }, + { + name: "all caps with accents", + input: "CAFÉ", + expected: "CAFE", + }, + { + name: "sentence with multiple accented words", + input: "The naïve café has a piñata", + expected: "The naive cafe has a pinata", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := removeAccents(tt.input) + assert.Equal(t, tt.expected, result, "removeAccents(%q) should return %q", tt.input, tt.expected) + }) + } +} + +func TestIsDirectMessage(t *testing.T) { + tests := []struct { + name string + channelType model.ChannelType + shouldPanic bool + expectedValue bool + }{ + { + name: "direct message channel", + channelType: model.ChannelTypeDirect, + shouldPanic: false, + expectedValue: true, + }, + { + name: "group message channel", + channelType: model.ChannelTypeGroup, + shouldPanic: false, + expectedValue: false, + }, + { + name: "public channel", + channelType: model.ChannelTypeOpen, + shouldPanic: false, + expectedValue: false, + }, + { + name: "private channel", + channelType: model.ChannelTypePrivate, + shouldPanic: false, + expectedValue: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := Plugin{} + channelID := model.NewId() + + mockAPI := &MockAPIWithChannel{ + GetChannelFunc: func(id string) (*model.Channel, *model.AppError) { + if id == channelID { + return &model.Channel{ + Id: id, + Type: tt.channelType, + }, nil + } + return nil, &model.AppError{Message: "channel not found"} + }, + } + p.SetAPI(mockAPI) + + result := p.isDirectMessage(channelID) + assert.Equal(t, tt.expectedValue, result) + }) + } + + t.Run("panics when channel not found", func(t *testing.T) { + p := Plugin{} + invalidChannelID := "invalid-channel-id" + + mockAPI := &MockAPIWithChannel{ + GetChannelFunc: func(id string) (*model.Channel, *model.AppError) { + return nil, &model.AppError{Message: "channel not found"} + }, + } + p.SetAPI(mockAPI) + + // This test documents the current behavior: the function panics + // NOTE: This is a design flaw - the function should return an error instead + assert.Panics(t, func() { + p.isDirectMessage(invalidChannelID) + }, "isDirectMessage should panic when channel is not found (current behavior)") + }) +} + +// MockAPIWithChannel extension for GetChannel +type MockAPIWithChannel struct { + MockAPI + GetChannelFunc func(channelID string) (*model.Channel, *model.AppError) +} + +func (m *MockAPIWithChannel) GetChannel(channelID string) (*model.Channel, *model.AppError) { + if m.GetChannelFunc != nil { + return m.GetChannelFunc(channelID) + } + return &model.Channel{Id: channelID, Type: model.ChannelTypeOpen}, nil +} + +func TestSendUserEphemeralMessageForPost(t *testing.T) { + tests := []struct { + name string + post *model.Post + message string + expectedUserID string + expectedChannel string + expectedMessage string + expectedRootID string + }{ + { + name: "send ephemeral message for regular post", + post: &model.Post{ + UserId: "user123", + ChannelId: "channel456", + Message: "Original message", + }, + message: "This is an ephemeral message", + expectedUserID: "user123", + expectedChannel: "channel456", + expectedMessage: "This is an ephemeral message", + expectedRootID: "", + }, + { + name: "send ephemeral message for reply in thread", + post: &model.Post{ + UserId: "user123", + ChannelId: "channel456", + RootId: "root789", + Message: "Reply message", + }, + message: "Warning message", + expectedUserID: "user123", + expectedChannel: "channel456", + expectedMessage: "Warning message", + expectedRootID: "root789", + }, + { + name: "send ephemeral with empty message", + post: &model.Post{ + UserId: "user123", + ChannelId: "channel456", + }, + message: "", + expectedUserID: "user123", + expectedChannel: "channel456", + expectedMessage: "", + expectedRootID: "", + }, + { + name: "send ephemeral with special characters in message", + post: &model.Post{ + UserId: "user123", + ChannelId: "channel456", + }, + message: "Warning: **Bold** and `code` with [link](https://example.com)", + expectedUserID: "user123", + expectedChannel: "channel456", + expectedMessage: "Warning: **Bold** and `code` with [link](https://example.com)", + expectedRootID: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := Plugin{} + + // Use the existing MockAPI that implements SendEphemeralPost + mockAPI := &MockAPI{} + p.SetAPI(mockAPI) + + // This test verifies that the function: + // 1. Doesn't panic + // 2. Creates the ephemeral post with correct structure + // The MockAPI.SendEphemeralPost implementation is a no-op that returns the post + // which is sufficient for this unit test + + assert.NotPanics(t, func() { + p.sendUserEphemeralMessageForPost(tt.post, tt.message) + }, "sendUserEphemeralMessageForPost should not panic") + }) + } +} + +func TestEndsWith(t *testing.T) { + tests := []struct { + name string + search []string + email string + expected bool + }{ + { + name: "exact domain match", + search: []string{"bad.com", "spam.com"}, + email: "user@bad.com", + expected: true, + }, + { + name: "second domain in list matches", + search: []string{"bad.com", "spam.com"}, + email: "user@spam.com", + expected: true, + }, + { + name: "no match", + search: []string{"bad.com", "spam.com"}, + email: "user@good.com", + expected: false, + }, + { + name: "subdomain does not match parent", + search: []string{"bad.com"}, + email: "user@sub.bad.com", + expected: false, + }, + { + name: "parent domain does not match subdomain", + search: []string{"sub.bad.com"}, + email: "user@bad.com", + expected: false, + }, + { + name: "empty search list", + search: []string{}, + email: "user@anything.com", + expected: false, + }, + { + name: "single domain in search", + search: []string{"blocked.com"}, + email: "user@blocked.com", + expected: true, + }, + { + name: "case sensitive matching (domain part should match case)", + search: []string{"bad.com"}, + email: "user@BAD.COM", + expected: false, + }, + { + name: "exact match with case", + search: []string{"BAD.COM"}, + email: "user@BAD.COM", + expected: true, + }, + { + name: "multiple @ symbols (malformed email)", + search: []string{"bad.com"}, + email: "user@test@bad.com", + expected: false, // SplitN with limit 2 splits on first @, so domain is "test@bad.com", not "bad.com" + }, + { + name: "domain with port", + search: []string{"bad.com:8080"}, + email: "user@bad.com:8080", + expected: true, + }, + { + name: "email with plus addressing", + search: []string{"bad.com"}, + email: "user+tag@bad.com", + expected: true, + }, + { + name: "domain with hyphen", + search: []string{"bad-domain.com"}, + email: "user@bad-domain.com", + expected: true, + }, + { + name: "TLD only", + search: []string{"com"}, + email: "user@example.com", + expected: false, + }, + { + name: "partial domain match should not work", + search: []string{"domain.com"}, + email: "user@bad-domain.com", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := EndsWith(tt.search, tt.email) + assert.Equal(t, tt.expected, result, "EndsWith(%v, %q) should return %v", tt.search, tt.email, tt.expected) + }) + } +} + +// TestEndsWithEdgeCases tests edge cases and potential security issues +func TestEndsWithEdgeCases(t *testing.T) { + t.Run("email without @ symbol panics or has undefined behavior", func(t *testing.T) { + // This test documents current behavior + // The function will panic or have undefined behavior if email doesn't contain @ + search := []string{"bad.com"} + email := "userAtbad.com" // No @ symbol + + // The function expects email to have @ symbol + // This will panic with index out of range + assert.Panics(t, func() { + EndsWith(search, email) + }, "EndsWith should panic or fail gracefully when email has no @ symbol") + }) + + t.Run("handles many domains in search list", func(t *testing.T) { + // Create a large search list + search := make([]string, 1000) + for i := 0; i < 1000; i++ { + search[i] = "domain" + string(rune(i)) + ".com" + } + search[999] = "found.com" + + email := "user@found.com" + result := EndsWith(search, email) + assert.True(t, result, "Should find domain even in large list") + }) + + t.Run("empty email after @ symbol", func(t *testing.T) { + search := []string{""} + email := "user@" + + // This should match empty string domain + result := EndsWith(search, email) + assert.True(t, result) + }) + + t.Run("nil search list causes no panic", func(t *testing.T) { + email := "user@bad.com" + + // Should not panic with nil slice + result := EndsWith(nil, email) + assert.False(t, result) + }) +} + +// TestRemoveAccentsPerformance ensures removeAccents doesn't regress +func TestRemoveAccentsPerformance(t *testing.T) { + // This is more of a smoke test than a real benchmark + // Real benchmarks should use testing.B + longText := "café " + "niño " + "Müller " + longText = longText + longText + longText // Make it longer + + result := removeAccents(longText) + assert.NotEmpty(t, result, "Should handle longer text") + assert.NotContains(t, result, "é", "Should remove accents from repeated text") +} diff --git a/server/validators.go b/server/validators.go index c468b80..ea51f78 100644 --- a/server/validators.go +++ b/server/validators.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "strings" "github.com/mattermost/mattermost/server/public/model" ) @@ -11,8 +12,15 @@ func (p *Plugin) checkBadEmail(user *model.User) error { if p.configuration.BuiltinBadDomains && EndsWith(*p.badDomainsList, email) { return fmt.Errorf("email domain is in builtin list of bad domains: %v", email) } - if p.badDomainsRegex != nil && p.badDomainsRegex.MatchString(email) { - return fmt.Errorf("email domain matches moderations list: %v", email) + if p.badDomainsRegex != nil { + // Extract domain from email for regex matching + parts := strings.SplitN(email, "@", 2) + if len(parts) == 2 { + domain := parts[1] + if p.badDomainsRegex.MatchString(domain) { + return fmt.Errorf("email domain matches moderations list: %v", email) + } + } } return nil } diff --git a/server/validators_test.go b/server/validators_test.go new file mode 100644 index 0000000..0219007 --- /dev/null +++ b/server/validators_test.go @@ -0,0 +1,377 @@ +package main + +import ( + "regexp" + "testing" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/assert" +) + +func TestCheckBadEmail(t *testing.T) { + // Setup bad domains regex for testing (using domain template without word boundaries) + badDomainsRegex, _ := splitWordListToRegex("spam.com,junk.org", `(?mi)(%s)`) + + // Setup builtin bad domains list + builtinDomains := []string{"hoo.com", "disposable.email"} + + tests := []struct { + name string + email string + badDomainsRegex *regexp.Regexp + builtinBadDomains bool + badDomainsList *[]string + expectError bool + errorContains string + }{ + { + name: "valid email with good domain", + email: "user@example.com", + badDomainsRegex: badDomainsRegex, + builtinBadDomains: false, + expectError: false, + }, + { + name: "email matches custom bad domain regex", + email: "user@spam.com", + badDomainsRegex: badDomainsRegex, + builtinBadDomains: false, + expectError: true, + errorContains: "matches moderations list", + }, + { + name: "email matches builtin bad domain list", + email: "user@hoo.com", + badDomainsRegex: badDomainsRegex, + builtinBadDomains: true, + badDomainsList: &builtinDomains, + expectError: true, + errorContains: "builtin list of bad domains", + }, + { + name: "email with subdomain containing bad domain", + email: "user@sub.spam.com", + badDomainsRegex: badDomainsRegex, + builtinBadDomains: false, + expectError: true, // Regex matches spam.com within sub.spam.com + errorContains: "matches moderations list", + }, + { + name: "email matches second domain in list", + email: "user@junk.org", + badDomainsRegex: badDomainsRegex, + builtinBadDomains: false, + expectError: true, + errorContains: "matches moderations list", + }, + { + name: "nil regex allows all emails", + email: "user@spam.com", + badDomainsRegex: nil, + builtinBadDomains: false, + expectError: false, + }, + { + name: "builtin disabled does not check builtin list", + email: "user@hoo.com", + badDomainsRegex: nil, + builtinBadDomains: false, + badDomainsList: &builtinDomains, + expectError: false, + }, + { + name: "case insensitive domain matching", + email: "user@SPAM.COM", + badDomainsRegex: badDomainsRegex, + builtinBadDomains: false, + expectError: true, + errorContains: "matches moderations list", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := Plugin{ + configuration: &configuration{ + BuiltinBadDomains: tt.builtinBadDomains, + }, + badDomainsRegex: tt.badDomainsRegex, + badDomainsList: tt.badDomainsList, + } + + user := &model.User{Email: tt.email} + err := p.checkBadEmail(user) + + if tt.expectError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestCheckBadUsername(t *testing.T) { + // Setup bad usernames regex for testing + badUsernamesRegex, _ := splitWordListToRegex("spammer,bot,admin", `(?mi)(%s)`) + + tests := []struct { + name string + username string + nickname string + badUsernamesRegex *regexp.Regexp + expectError bool + errorContains string + }{ + { + name: "valid username and nickname", + username: "gooduser", + nickname: "Good User", + badUsernamesRegex: badUsernamesRegex, + expectError: false, + }, + { + name: "username matches bad pattern", + username: "spammer123", + nickname: "Good User", + badUsernamesRegex: badUsernamesRegex, + expectError: true, + errorContains: "matches moderation list", + }, + { + name: "nickname matches bad pattern", + username: "gooduser", + nickname: "I am a bot", + badUsernamesRegex: badUsernamesRegex, + expectError: true, + errorContains: "matches moderation list", + }, + { + name: "both username and nickname match", + username: "botspammer", + nickname: "Admin Bot", + badUsernamesRegex: badUsernamesRegex, + expectError: true, + errorContains: "matches moderation list", + }, + { + name: "nil regex allows all usernames", + username: "spammer", + nickname: "bot admin", + badUsernamesRegex: nil, + expectError: false, + }, + { + name: "empty username and nickname", + username: "", + nickname: "", + badUsernamesRegex: badUsernamesRegex, + expectError: false, + }, + { + name: "case insensitive matching", + username: "SPAMMER", + nickname: "Good User", + badUsernamesRegex: badUsernamesRegex, + expectError: true, + errorContains: "matches moderation list", + }, + { + name: "partial match in username", + username: "notaspammer", + nickname: "Good User", + badUsernamesRegex: badUsernamesRegex, + expectError: true, + errorContains: "matches moderation list", + }, + { + name: "username with special characters", + username: "user.name+test", + nickname: "Test User", + badUsernamesRegex: badUsernamesRegex, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := Plugin{ + badUsernamesRegex: tt.badUsernamesRegex, + } + + user := &model.User{ + Username: tt.username, + Nickname: tt.nickname, + } + err := p.checkBadUsername(user) + + if tt.expectError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +// TestCheckBadEmailWithRealData tests email validation with realistic email patterns +func TestCheckBadEmailWithRealData(t *testing.T) { + // Setup realistic bad domains (using domain template without word boundaries) + badDomainsRegex, _ := splitWordListToRegex("tempmail.com,10minutemail.com,guerrillamail.com", `(?mi)(%s)`) + builtinDomains := []string{"throwaway.email", "fakeinbox.com"} + + p := Plugin{ + configuration: &configuration{ + BuiltinBadDomains: true, + }, + badDomainsRegex: badDomainsRegex, + badDomainsList: &builtinDomains, + } + + tests := []struct { + name string + email string + shouldBlock bool + }{ + // Valid emails + {"gmail", "user@gmail.com", false}, + {"corporate", "john.doe@company.com", false}, + {"subdomain", "admin@mail.company.com", false}, + {"with plus", "user+tag@example.com", false}, + {"with dash", "user-name@example.com", false}, + + // Bad emails - custom list + {"temp mail", "user@tempmail.com", true}, + {"10 minute mail", "user@10minutemail.com", true}, + {"guerrilla mail", "user@guerrillamail.com", true}, + + // Bad emails - builtin list + {"throwaway", "user@throwaway.email", true}, + {"fake inbox", "user@fakeinbox.com", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + user := &model.User{Email: tt.email} + err := p.checkBadEmail(user) + + if tt.shouldBlock { + assert.Error(t, err, "Expected %s to be blocked", tt.email) + } else { + assert.NoError(t, err, "Expected %s to be allowed", tt.email) + } + }) + } +} + +// TestCheckBadUsernameWithRealData tests username validation with realistic patterns +func TestCheckBadUsernameWithRealData(t *testing.T) { + // Setup realistic bad username patterns + badUsernamesRegex, _ := splitWordListToRegex("admin,moderator,support,spam,bot", `(?mi)(%s)`) + + p := Plugin{ + badUsernamesRegex: badUsernamesRegex, + } + + tests := []struct { + name string + username string + nickname string + shouldBlock bool + }{ + // Valid usernames + {"regular user", "john_doe", "John Doe", false}, + {"with numbers", "user123", "User 123", false}, + {"with underscore", "super_user", "Super User", false}, + {"short name", "joe", "Joe", false}, + + // Bad usernames - contains reserved words + {"contains admin", "admin_user", "Admin User", true}, + {"contains moderator", "moderator123", "Mod", true}, + {"contains support", "support_team", "Support", true}, + {"contains spam", "spammer99", "Spammer", true}, + {"contains bot", "chatbot", "Bot", true}, + + // Bad nicknames + {"admin nickname", "gooduser", "Admin Person", true}, + {"bot nickname", "user123", "I'm a bot", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + user := &model.User{ + Username: tt.username, + Nickname: tt.nickname, + } + err := p.checkBadUsername(user) + + if tt.shouldBlock { + assert.Error(t, err, "Expected %s/%s to be blocked", tt.username, tt.nickname) + } else { + assert.NoError(t, err, "Expected %s/%s to be allowed", tt.username, tt.nickname) + } + }) + } +} + +// TestCheckBadEmailWithRegexPatterns tests email validation with regex patterns +func TestCheckBadEmailWithRegexPatterns(t *testing.T) { + // Test regex patterns like .*spam and spam.* + badDomainsRegex, err := splitWordListToRegex("10minutemail.com, tempmail.com, .*spam, spam.*", `(?mi)(%s)`) + assert.NoError(t, err) + assert.NotNil(t, badDomainsRegex) + + p := Plugin{ + configuration: &configuration{ + BuiltinBadDomains: false, + }, + badDomainsRegex: badDomainsRegex, + badDomainsList: &[]string{}, + } + + tests := []struct { + name string + email string + shouldBlock bool + }{ + // Valid emails + {"good domain", "user@example.com", false}, + {"another good domain", "user@company.org", false}, + {"domain with spam in middle", "user@gooddomain.com", false}, + + // Should match exact domains + {"tempmail.com", "user@tempmail.com", true}, + {"10minutemail.com", "user@10minutemail.com", true}, + + // Should match regex patterns + {"domain ending with spam", "user@spamdomain.com", true}, // matches spam.* + {"domain containing spam", "user@testspam.com", true}, // matches .*spam + {"domain starting with spam", "user@spamtest.com", true}, // matches spam.* + {"spam in middle", "user@testspamdomain.com", true}, // matches .*spam + {"spam at end", "user@testspam.com", true}, // matches both patterns + {"domain is spam", "user@spam.com", true}, // matches both patterns + + // Edge cases + {"spam as subdomain", "user@spam.subdomain.com", true}, // matches spam.* + {"spam in subdomain", "user@subdomain.spam.com", true}, // matches .*spam + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + user := &model.User{Email: tt.email} + err := p.checkBadEmail(user) + + if tt.shouldBlock { + assert.Error(t, err, "Expected %s to be blocked", tt.email) + } else { + assert.NoError(t, err, "Expected %s to be allowed", tt.email) + } + }) + } +}