aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/go.yml2
-rw-r--r--.github/workflows/homebrew-release.yml54
-rw-r--r--.gitignore3
-rw-r--r--Dockerfile2
-rw-r--r--README.md89
-rw-r--r--api.yaml278
-rw-r--r--cmd/mobius-hotline-server/main.go162
-rw-r--r--cmd/mobius-hotline-server/main_test.go181
-rw-r--r--cmd/mobius-hotline-server/mobius/config/config.yaml2
-rw-r--r--docs/tls.md101
-rw-r--r--go.mod31
-rw-r--r--go.sum110
-rw-r--r--hotline/access.go164
-rw-r--r--hotline/access_test.go407
-rw-r--r--hotline/account_manager.go2
-rw-r--r--hotline/config.go2
-rw-r--r--hotline/field.go125
-rw-r--r--hotline/field_test.go77
-rw-r--r--hotline/file_path.go8
-rw-r--r--hotline/file_resume_data.go62
-rw-r--r--hotline/file_transfer.go121
-rw-r--r--hotline/file_transfer_test.go91
-rw-r--r--hotline/file_wrapper.go54
-rw-r--r--hotline/files.go12
-rw-r--r--hotline/files_test.go2
-rw-r--r--hotline/flattened_file_object.go4
-rw-r--r--hotline/news.go25
-rw-r--r--hotline/news_test.go271
-rw-r--r--hotline/server.go277
-rw-r--r--hotline/server_test.go583
-rw-r--r--hotline/stats.go4
-rw-r--r--hotline/stats_test.go293
-rw-r--r--hotline/tracker.go121
-rw-r--r--hotline/tracker_test.go383
-rw-r--r--hotline/transaction.go2
-rw-r--r--hotline/transfer_test.go6
-rw-r--r--internal/mobius/account_manager.go38
-rw-r--r--internal/mobius/account_manager_test.go438
-rw-r--r--internal/mobius/api.go226
-rw-r--r--internal/mobius/ban.go6
-rw-r--r--internal/mobius/ban_test.go10
-rw-r--r--internal/mobius/config.go19
-rw-r--r--internal/mobius/config_test.go95
-rw-r--r--internal/mobius/news.go6
-rw-r--r--internal/mobius/news_test.go500
-rw-r--r--internal/mobius/threaded_news.go4
-rw-r--r--internal/mobius/threaded_news_test.go8
-rw-r--r--internal/mobius/transaction_handlers.go668
-rw-r--r--internal/mobius/transaction_handlers_test.go749
49 files changed, 5889 insertions, 989 deletions
diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index 3357c19..e155842 100644
--- a/.github/workflows/go.yml
+++ b/.github/workflows/go.yml
@@ -19,7 +19,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
- go-version: '1.23.3'
+ go-version: '1.25.4'
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v5
with:
diff --git a/.github/workflows/homebrew-release.yml b/.github/workflows/homebrew-release.yml
new file mode 100644
index 0000000..c703c86
--- /dev/null
+++ b/.github/workflows/homebrew-release.yml
@@ -0,0 +1,54 @@
+name: Update Homebrew Formulas
+
+on:
+ release:
+ types: [published]
+
+jobs:
+ update-homebrew:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Calculate SHA256 and update formulas
+ env:
+ GITHUB_TOKEN: ${{ secrets.HOMEBREW_UPDATE_TOKEN }}
+ run: |
+ TAG=${GITHUB_REF#refs/tags/}
+ SHA=$(curl -Ls https://github.com/jhalter/mobius/archive/refs/tags/$TAG.tar.gz | shasum -a 256 | cut -d' ' -f1)
+
+ echo "Updating Homebrew formulas for version $TAG with SHA $SHA"
+
+ # Update client formula
+ echo "Updating client formula..."
+ gh api repos/jhalter/homebrew-mobius-hotline-client/contents/mobius-hotline-client.rb \
+ --jq '.content' | base64 -d > client.rb
+
+ CURRENT_CLIENT_SHA=$(gh api repos/jhalter/homebrew-mobius-hotline-client/contents/mobius-hotline-client.rb --jq '.sha')
+
+ sed -i "s|url \"https://github.com/jhalter/mobius/archive/refs/tags/v.*\.tar\.gz\"|url \"https://github.com/jhalter/mobius/archive/refs/tags/$TAG.tar.gz\"|" client.rb
+ sed -i "s/version \".*\"/version \"${TAG#v}\"/" client.rb
+ sed -i "s/sha256 \".*\"/sha256 \"$SHA\"/" client.rb
+
+ gh api repos/jhalter/homebrew-mobius-hotline-client/contents/mobius-hotline-client.rb \
+ -X PUT \
+ -f message="Update to $TAG" \
+ -f content="$(base64 -w 0 client.rb)" \
+ -f sha="$CURRENT_CLIENT_SHA"
+
+ # Update server formula
+ echo "Updating server formula..."
+ gh api repos/jhalter/homebrew-mobius-hotline-server/contents/mobius-hotline-server.rb \
+ --jq '.content' | base64 -d > server.rb
+
+ CURRENT_SERVER_SHA=$(gh api repos/jhalter/homebrew-mobius-hotline-server/contents/mobius-hotline-server.rb --jq '.sha')
+
+ sed -i "s|url \"https://github.com/jhalter/mobius/archive/refs/tags/v.*\.tar\.gz\"|url \"https://github.com/jhalter/mobius/archive/refs/tags/$TAG.tar.gz\"|" server.rb
+ sed -i "s/version \".*\"/version \"${TAG#v}\"/" server.rb
+ sed -i "s/sha256 \".*\"/sha256 \"$SHA\"/" server.rb
+
+ gh api repos/jhalter/homebrew-mobius-hotline-server/contents/mobius-hotline-server.rb \
+ -X PUT \
+ -f message="Update to $TAG" \
+ -f content="$(base64 -w 0 server.rb)" \
+ -f sha="$CURRENT_SERVER_SHA"
+
+ echo "Successfully updated both Homebrew formulas to $TAG" \ No newline at end of file
diff --git a/.gitignore b/.gitignore
index c310ebd..b8c6737 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,3 +27,6 @@ dist/*
# Ignore the bind mounted config directory
config/*
+
+# Claude Code IDE files
+.claude
diff --git a/Dockerfile b/Dockerfile
index f5b2af6..414ed9c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM golang:1.22 AS builder
+FROM golang:1.25 AS builder
WORKDIR /app
COPY . .
diff --git a/README.md b/README.md
index c76ad4f..4a988ab 100644
--- a/README.md
+++ b/README.md
@@ -38,7 +38,7 @@ See [Releases](https://github.com/jhalter/mobius/releases) page.
To run a Hotline server with a default, sample configuration with ports forwarded from the host OS to the container:
- docker run --rm -p 5500:5500 -p 5501:5501 ghcr.io/jhalter/mobius:latest
+ docker run --rm -p 5500:5500 -p 5501:5501 ghcr.io/jhalter/mobius-hotline-server:latest
You can now connect to localhost:5500 with your favorite Hotline client and play around, but all changes will be lost on container restart.
@@ -176,62 +176,73 @@ Usage of mobius-hotline-server:
To run as a systemd service, refer to this sample unit file: [mobius-hotline-server.service](https://github.com/jhalter/mobius/blob/master/cmd/mobius-hotline-server/mobius-hotline-server.service)
-## (Optional) HTTP API
+## HTTP API
-The Mobius server includes an optional HTTP API to perform out-of-band administrative functions.
+The Mobius server includes an optional HTTP API for server administration and user management.
-To enable it, include the `--api-addr` flag with a string defining the IP and port to listen on in the form of `<ip>:<port>`.
+### Configuration
-Example: `--api-addr=127.0.0.1:5503`
+To enable the API, use the `--api-addr` flag with an IP and port:
-⚠️ The API has no authentication, so binding it to localhost is a good idea!
+```bash
+mobius-hotline-server --api-addr=127.0.0.1:5503
+```
-#### GET /api/v1/stats
+### Authentication
-The stats endpoint returns server runtime statistics and counters.
+The API supports optional authentication via API key. Set the `--api-key` flag:
+```bash
+mobius-hotline-server --api-addr=127.0.0.1:5503 --api-key=your-secret-key
```
-❯ curl -s localhost:5503/api/v1/stats | jq .
-{
- "ConnectionCounter": 0,
- "ConnectionPeak": 0,
- "CurrentlyConnected": 0,
- "DownloadCounter": 0,
- "DownloadsInProgress": 0,
- "Since": "2024-07-18T15:36:42.426156-07:00",
- "UploadCounter": 0,
- "UploadsInProgress": 0,
- "WaitingDownloads": 0
-}
+
+Include the API key in the `X-API-Key` header:
+
+```bash
+curl -H "X-API-Key: your-secret-key" localhost:5503/api/v1/stats
```
-#### GET /api/v1/reload
+### API Documentation
-The reload endpoint reloads the following configuration files from disk:
+Complete API documentation is available in the [OpenAPI specification](api.yaml).
-* Agreement.txt
-* News.txt
-* Users/*.yaml
-* ThreadedNews.yaml
-* banner.jpg
+### Endpoints
-Example:
+#### User Management
-```
-❯ curl -s localhost:5503/api/v1/reload | jq .
-{
- "msg": "config reloaded"
-}
-```
+- `GET /api/v1/online` - List currently online users
+- `POST /api/v1/ban` - Ban a user by username, nickname, or IP
+- `POST /api/v1/unban` - Remove a ban
+- `GET /api/v1/banned/ips` - List banned IP addresses
+- `GET /api/v1/banned/usernames` - List banned usernames
+- `GET /api/v1/banned/nicknames` - List banned nicknames
-#### POST /api/v1/shutdown
+#### Server Administration
-The shutdown endpoint accepts a shutdown message from POST payload, sends it to to all connected Hotline clients, then gracefully shuts down the server.
+- `GET /api/v1/stats` - Get server statistics
+- `POST /api/v1/reload` - Reload configuration
+- `POST /api/v1/shutdown` - Shutdown server
-Example:
+### Examples
+
+**Get online users:**
+```bash
+curl localhost:5503/api/v1/online
+```
+
+**Ban a user:**
+```bash
+curl -X POST -H "Content-Type: application/json" \
+ -d '{"username":"baduser"}' \
+ localhost:5503/api/v1/ban
+```
+**Get server stats:**
+```bash
+curl localhost:5503/api/v1/stats | jq .
```
-❯ curl -d 'Server rebooting' localhost:5503/api/v1/shutdown
-{ "msg": "server shutting down" }
+**Shutdown server:**
+```bash
+curl -X POST -d 'Server maintenance' localhost:5503/api/v1/shutdown
```
diff --git a/api.yaml b/api.yaml
new file mode 100644
index 0000000..fe6060d
--- /dev/null
+++ b/api.yaml
@@ -0,0 +1,278 @@
+openapi: 3.0.3
+info:
+ title: Mobius API
+ description: REST API for managing a Hotline server
+ version: 1.0.0
+ contact:
+ name: Mobius
+ url: https://github.com/jhalter/mobius
+servers:
+ - url: http://localhost:5603
+ description: Local development server
+security:
+ - ApiKeyAuth: []
+paths:
+ /api/v1/online:
+ get:
+ summary: Get online users
+ description: Returns a list of currently online users with their login, nickname, and IP address
+ responses:
+ '200':
+ description: List of online users
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ type: object
+ properties:
+ login:
+ type: string
+ description: User's login name
+ nickname:
+ type: string
+ description: User's display nickname
+ ip:
+ type: string
+ description: User's IP address
+ example:
+ login: "admin"
+ nickname: "Administrator"
+ ip: "192.168.1.100"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ /api/v1/ban:
+ post:
+ summary: Ban a user
+ description: Ban a user by username, nickname, or IP address. At least one field must be provided.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ username:
+ type: string
+ description: Username to ban
+ nickname:
+ type: string
+ description: Nickname to ban
+ ip:
+ type: string
+ description: IP address to ban
+ example:
+ username: "baduser"
+ responses:
+ '200':
+ description: User banned successfully
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ msg:
+ type: string
+ example: "banned"
+ '400':
+ description: Bad request - missing required fields
+ content:
+ text/plain:
+ schema:
+ type: string
+ example: "username, nickname, or ip required"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ /api/v1/unban:
+ post:
+ summary: Unban a user
+ description: Remove a ban for a user by username, nickname, or IP address. At least one field must be provided.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ username:
+ type: string
+ description: Username to unban
+ nickname:
+ type: string
+ description: Nickname to unban
+ ip:
+ type: string
+ description: IP address to unban
+ example:
+ username: "baduser"
+ responses:
+ '200':
+ description: User unbanned successfully
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ msg:
+ type: string
+ example: "unbanned"
+ '400':
+ description: Bad request - missing required fields
+ content:
+ text/plain:
+ schema:
+ type: string
+ example: "username, nickname, or ip required"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ /api/v1/banned/ips:
+ get:
+ summary: List banned IP addresses
+ description: Returns a list of all banned IP addresses
+ responses:
+ '200':
+ description: List of banned IP addresses
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ type: string
+ example: ["192.168.1.100", "10.0.0.5"]
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '500':
+ description: Internal server error
+ content:
+ text/plain:
+ schema:
+ type: string
+ example: "failed to fetch banned IPs"
+ /api/v1/banned/usernames:
+ get:
+ summary: List banned usernames
+ description: Returns a list of all banned usernames
+ responses:
+ '200':
+ description: List of banned usernames
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ type: string
+ example: ["baduser", "spammer"]
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '500':
+ description: Internal server error
+ content:
+ text/plain:
+ schema:
+ type: string
+ example: "failed to fetch banned usernames"
+ /api/v1/banned/nicknames:
+ get:
+ summary: List banned nicknames
+ description: Returns a list of all banned nicknames
+ responses:
+ '200':
+ description: List of banned nicknames
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ type: string
+ example: ["BadNick", "Spammer123"]
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '500':
+ description: Internal server error
+ content:
+ text/plain:
+ schema:
+ type: string
+ example: "failed to fetch banned nicknames"
+ /api/v1/reload:
+ post:
+ summary: Reload server configuration
+ description: Triggers a reload of the server configuration
+ responses:
+ '200':
+ description: Configuration reloaded successfully
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ msg:
+ type: string
+ example: "config reloaded"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ /api/v1/shutdown:
+ post:
+ summary: Shutdown server
+ description: Gracefully shutdown the server with a message
+ requestBody:
+ required: true
+ content:
+ text/plain:
+ schema:
+ type: string
+ example: "Server maintenance"
+ responses:
+ '200':
+ description: Server shutting down
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ msg:
+ type: string
+ example: "server shutting down"
+ '400':
+ description: Bad request - missing shutdown message
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ /api/v1/stats:
+ get:
+ summary: Get server statistics
+ description: Returns current server statistics and metrics
+ responses:
+ '200':
+ description: Server statistics
+ content:
+ application/json:
+ schema:
+ type: object
+ description: Server statistics object (structure depends on implementation)
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '500':
+ description: Internal server error
+ content:
+ text/plain:
+ schema:
+ type: string
+ example: "failed to marshal stats"
+components:
+ securitySchemes:
+ ApiKeyAuth:
+ type: apiKey
+ in: header
+ name: X-API-Key
+ description: API key for authentication
+ responses:
+ Unauthorized:
+ description: Authentication required
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ error:
+ type: string
+ example: "unauthorized" \ No newline at end of file
diff --git a/cmd/mobius-hotline-server/main.go b/cmd/mobius-hotline-server/main.go
index e4e0954..cc24f5f 100644
--- a/cmd/mobius-hotline-server/main.go
+++ b/cmd/mobius-hotline-server/main.go
@@ -2,19 +2,20 @@ package main
import (
"context"
+ "crypto/tls"
"embed"
"flag"
"fmt"
- "github.com/jhalter/mobius/hotline"
- "github.com/jhalter/mobius/internal/mobius"
- "github.com/oleksandr/bonjour"
"io"
"log"
"os"
"os/signal"
"path"
- "path/filepath"
"syscall"
+
+ "github.com/jhalter/mobius/hotline"
+ "github.com/jhalter/mobius/internal/mobius"
+ "github.com/oleksandr/bonjour"
)
//go:embed mobius/config
@@ -35,11 +36,18 @@ func main() {
netInterface := flag.String("interface", "", "IP addr of interface to listen on. Defaults to all interfaces.")
basePort := flag.Int("bind", 5500, "Base Hotline server port. File transfer port is base port + 1.")
apiAddr := flag.String("api-addr", "", "Enable HTTP API endpoint on address and port")
- configDir := flag.String("config", configSearchPaths(), "Path to config root")
+ apiKey := flag.String("api-key", "", "API key required for HTTP API authentication")
+ redisAddr := flag.String("redis-addr", "", "Redis server address for API features")
+ redisPassword := flag.String("redis-password", "", "Redis password, if required")
+ redisDB := flag.Int("redis-db", 0, "Redis DB number, defaults to 0")
+ configDir := flag.String("config", findConfigPath(), "Path to config root")
printVersion := flag.Bool("version", false, "Print version and exit")
logLevel := flag.String("log-level", "info", "Log level")
logFile := flag.String("log-file", "", "Path to log file")
init := flag.Bool("init", false, "Populate the config dir with default configuration")
+ tlsCert := flag.String("tls-cert", "", "Path to TLS certificate file")
+ tlsKey := flag.String("tls-key", "", "Path to TLS key file")
+ tlsPort := flag.Int("tls-port", 5600, "Base TLS port. TLS file transfer port is base + 1.")
flag.Parse()
@@ -55,11 +63,11 @@ func main() {
if *init {
if _, err := os.Stat(path.Join(*configDir, "/config.yaml")); os.IsNotExist(err) {
if err := os.MkdirAll(*configDir, 0750); err != nil {
- slogger.Error(fmt.Sprintf("error creating config dir: %s", err))
+ slogger.Error("Error creating config dir", "err", err)
os.Exit(1)
}
if err := copyDir(path.Join("mobius", "config"), *configDir); err != nil {
- slogger.Error(fmt.Sprintf("error copying config dir: %s", err))
+ slogger.Error("Error copying config dir", "err", err)
os.Exit(1)
}
slogger.Info("Config dir initialized at " + *configDir)
@@ -70,55 +78,70 @@ func main() {
config, err := mobius.LoadConfig(path.Join(*configDir, "config.yaml"))
if err != nil {
- slogger.Error(fmt.Sprintf("Error loading config: %v", err))
+ slogger.Error("Error loading config", "err", err)
os.Exit(1)
}
- srv, err := hotline.NewServer(
+ var tlsConfig *tls.Config
+ if *tlsCert != "" && *tlsKey != "" {
+ cert, err := tls.LoadX509KeyPair(*tlsCert, *tlsKey)
+ if err != nil {
+ slogger.Error("Error loading TLS certificate", "err", err)
+ os.Exit(1)
+ }
+ tlsConfig = &tls.Config{Certificates: []tls.Certificate{cert}}
+ }
+
+ opts := []hotline.Option{
hotline.WithInterface(*netInterface),
hotline.WithLogger(slogger),
hotline.WithPort(*basePort),
hotline.WithConfig(*config),
- )
+ }
+ if tlsConfig != nil {
+ opts = append(opts, hotline.WithTLS(tlsConfig, *tlsPort))
+ }
+
+ srv, err := hotline.NewServer(opts...)
if err != nil {
- slogger.Error(fmt.Sprintf("Error starting server: %s", err))
+ slogger.Error("Error starting server", "err", err)
os.Exit(1)
}
srv.MessageBoard, err = mobius.NewFlatNews(path.Join(*configDir, "MessageBoard.txt"))
if err != nil {
- slogger.Error(fmt.Sprintf("Error loading message board: %v", err))
+ slogger.Error("Error loading message board", "err", err)
os.Exit(1)
}
srv.BanList, err = mobius.NewBanFile(path.Join(*configDir, "Banlist.yaml"))
if err != nil {
- slogger.Error(fmt.Sprintf("Error loading ban list: %v", err))
+ slogger.Error("Error loading ban list", "err", err)
os.Exit(1)
}
srv.ThreadedNewsMgr, err = mobius.NewThreadedNewsYAML(path.Join(*configDir, "ThreadedNews.yaml"))
if err != nil {
- slogger.Error(fmt.Sprintf("Error loading news: %v", err))
+ slogger.Error("Error loading news", "err", err)
os.Exit(1)
}
- srv.AccountManager, err = mobius.NewYAMLAccountManager(filepath.Join(*configDir, "Users/"))
+ srv.AccountManager, err = mobius.NewYAMLAccountManager(path.Join(*configDir, "Users/"))
if err != nil {
- slogger.Error(fmt.Sprintf("Error loading accounts: %v", err))
+ slogger.Error("Error loading accounts", "err", err)
os.Exit(1)
}
srv.Agreement, err = mobius.NewAgreement(*configDir, "\r")
if err != nil {
- slogger.Error(fmt.Sprintf("Error loading agreement: %v", err))
+ slogger.Error("Error loading agreement", "err", err)
os.Exit(1)
}
- bannerPath := filepath.Join(*configDir, config.BannerFile)
+ bannerPath := path.Join(*configDir, config.BannerFile)
srv.Banner, err = os.ReadFile(bannerPath)
if err != nil {
- slogger.Error(fmt.Sprintf("Error loading accounts: %v", err))
+ slogger.Error("Error loading banner", "err", err)
os.Exit(1)
}
@@ -136,13 +159,19 @@ func main() {
}
if err := srv.Agreement.(*mobius.Agreement).Reload(); err != nil {
- slogger.Error(fmt.Sprintf("Error reloading agreement: %v", err))
- os.Exit(1)
+ slogger.Error("Error reloading agreement", "err", err)
+ }
+
+ // Let's try to reload the banner
+ bannerPath := path.Join(*configDir, config.BannerFile)
+ srv.Banner, err = os.ReadFile(bannerPath)
+ if err != nil {
+ slogger.Error("Error reloading banner", "err", err)
}
}
if *apiAddr != "" {
- sh := mobius.NewAPIServer(srv, reloadFunc, slogger)
+ sh := mobius.NewAPIServer(srv, reloadFunc, slogger, *apiKey, *redisAddr, *redisPassword, *redisDB)
go sh.Serve(*apiAddr)
}
@@ -164,6 +193,9 @@ func main() {
}()
slogger.Info("Hotline server started", "version", version, "config", *configDir)
+ if tlsConfig != nil {
+ slogger.Info("TLS enabled", "port", *tlsPort, "fileTransferPort", *tlsPort+1)
+ }
// Assign functions to handle specific Hotline transaction types
mobius.RegisterHandlers(srv)
@@ -180,61 +212,73 @@ func main() {
log.Fatal(srv.ListenAndServe(ctx))
}
-func configSearchPaths() string {
+// findConfigPath searches for an existing config directory from the predefined search order.
+// Returns the first directory that exists, or falls back to "config" as the default.
+func findConfigPath() string {
for _, cfgPath := range mobius.ConfigSearchOrder {
- if _, err := os.Stat(cfgPath); err == nil {
+ if info, err := os.Stat(cfgPath); err == nil && info.IsDir() {
return cfgPath
}
}
+ // Default fallback - will be created by --init flag if needed
return "config"
}
-// copyDir recursively copies a directory tree, attempting to preserve permissions.
+// copyDir recursively copies a directory tree from embedded filesystem to local filesystem.
func copyDir(src, dst string) error {
+ return copyDirRecursive(src, dst)
+}
+
+// copyDirRecursive handles the recursive copying logic.
+func copyDirRecursive(src, dst string) error {
entries, err := cfgTemplate.ReadDir(src)
if err != nil {
- return err
+ return fmt.Errorf("failed to read source directory %s: %w", src, err)
}
- for _, dirEntry := range entries {
- if dirEntry.IsDir() {
- if err := os.MkdirAll(path.Join(dst, dirEntry.Name()), 0777); err != nil {
- panic(err)
- }
- subdirEntries, _ := cfgTemplate.ReadDir(path.Join(src, dirEntry.Name()))
- for _, subDirEntry := range subdirEntries {
- f, err := os.Create(path.Join(dst, dirEntry.Name(), subDirEntry.Name()))
- if err != nil {
- return err
- }
- srcFile, err := cfgTemplate.Open(path.Join(src, dirEntry.Name(), subDirEntry.Name()))
- if err != nil {
- return fmt.Errorf("error copying srcFile: %w", err)
- }
- _, err = io.Copy(f, srcFile)
- if err != nil {
- return err
- }
- _ = f.Close()
- }
- } else {
- f, err := os.Create(path.Join(dst, dirEntry.Name()))
- if err != nil {
- return err
- }
+ for _, entry := range entries {
+ srcPath := path.Join(src, entry.Name())
+ dstPath := path.Join(dst, entry.Name())
- srcFile, err := cfgTemplate.Open(path.Join(src, dirEntry.Name()))
- if err != nil {
- return err
+ if entry.IsDir() {
+ // Create directory with proper permissions
+ if err := os.MkdirAll(dstPath, 0755); err != nil {
+ return fmt.Errorf("failed to create directory %s: %w", dstPath, err)
+ }
+
+ // Recursively copy subdirectory
+ if err := copyDirRecursive(srcPath, dstPath); err != nil {
+ return fmt.Errorf("failed to copy subdirectory %s: %w", srcPath, err)
}
- _, err = io.Copy(f, srcFile)
- if err != nil {
- return err
+ } else {
+ // Copy file
+ if err := copyFile(srcPath, dstPath); err != nil {
+ return fmt.Errorf("failed to copy file %s to %s: %w", srcPath, dstPath, err)
}
- _ = f.Close()
}
}
return nil
}
+
+// copyFile copies a single file from embedded filesystem to local filesystem.
+func copyFile(src, dst string) error {
+ srcFile, err := cfgTemplate.Open(src)
+ if err != nil {
+ return fmt.Errorf("failed to open source file: %w", err)
+ }
+ defer func() { _ = srcFile.Close() }()
+
+ dstFile, err := os.Create(dst)
+ if err != nil {
+ return fmt.Errorf("failed to create destination file: %w", err)
+ }
+ defer func() { _ = dstFile.Close() }()
+
+ if _, err := io.Copy(dstFile, srcFile); err != nil {
+ return fmt.Errorf("failed to copy file contents: %w", err)
+ }
+
+ return nil
+}
diff --git a/cmd/mobius-hotline-server/main_test.go b/cmd/mobius-hotline-server/main_test.go
new file mode 100644
index 0000000..dec2ebf
--- /dev/null
+++ b/cmd/mobius-hotline-server/main_test.go
@@ -0,0 +1,181 @@
+package main
+
+import (
+ "os"
+ "path"
+ "testing"
+
+ "github.com/jhalter/mobius/internal/mobius"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCopyDir(t *testing.T) {
+ // Test using the actual embedded config directory
+ dstDir := t.TempDir()
+
+ // Execute copyDir with the embedded mobius/config directory
+ err := copyDir("mobius/config", dstDir)
+ require.NoError(t, err)
+
+ // Verify some expected files exist (based on the embedded config)
+ expectedFiles := []string{
+ "config.yaml",
+ "Agreement.txt",
+ "MessageBoard.txt",
+ "ThreadedNews.yaml",
+ "Users/admin.yaml",
+ "Users/guest.yaml",
+ "banner.jpg",
+ }
+
+ for _, expectedFile := range expectedFiles {
+ fullPath := path.Join(dstDir, expectedFile)
+ assert.FileExists(t, fullPath, "Expected file %s to exist", expectedFile)
+
+ // Verify file is not empty
+ info, err := os.Stat(fullPath)
+ require.NoError(t, err)
+ assert.Greater(t, info.Size(), int64(0), "File %s should not be empty", expectedFile)
+ }
+
+ // Verify directories were created
+ expectedDirs := []string{
+ "Users",
+ "Files",
+ }
+
+ for _, expectedDir := range expectedDirs {
+ fullPath := path.Join(dstDir, expectedDir)
+ info, err := os.Stat(fullPath)
+ require.NoError(t, err)
+ assert.True(t, info.IsDir(), "Expected %s to be a directory", expectedDir)
+ }
+}
+
+func TestCopyDirNonexistentSource(t *testing.T) {
+ dstDir := t.TempDir()
+
+ err := copyDir("nonexistent/directory", dstDir)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to read source directory")
+}
+
+func TestCopyDirRecursive(t *testing.T) {
+ // Test the recursive functionality using embedded config
+ dstDir := t.TempDir()
+
+ err := copyDirRecursive("mobius/config", dstDir)
+ require.NoError(t, err)
+
+ // Verify nested structure is copied correctly
+ nestedPath := path.Join(dstDir, "Users", "admin.yaml")
+ assert.FileExists(t, nestedPath)
+
+ // Verify nested Files directory
+ filesDir := path.Join(dstDir, "Files")
+ info, err := os.Stat(filesDir)
+ require.NoError(t, err)
+ assert.True(t, info.IsDir())
+}
+
+func TestCopyFile(t *testing.T) {
+ dstDir := t.TempDir()
+ dstFile := path.Join(dstDir, "copied.yaml")
+
+ // Copy a single file from embedded config
+ err := copyFile("mobius/config/config.yaml", dstFile)
+ require.NoError(t, err)
+
+ // Verify file was copied correctly
+ assert.FileExists(t, dstFile)
+
+ // Verify file is not empty
+ info, err := os.Stat(dstFile)
+ require.NoError(t, err)
+ assert.Greater(t, info.Size(), int64(0))
+}
+
+func TestCopyFileErrors(t *testing.T) {
+ t.Run("source file does not exist", func(t *testing.T) {
+ dstDir := t.TempDir()
+ err := copyFile("nonexistent.txt", path.Join(dstDir, "dest.txt"))
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to open source file")
+ })
+
+ t.Run("destination directory does not exist", func(t *testing.T) {
+ err := copyFile("mobius/config/config.yaml", "/nonexistent/directory/dest.txt")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to create destination file")
+ })
+}
+
+func TestCopyDirPermissions(t *testing.T) {
+ dstDir := t.TempDir()
+
+ err := copyDir("mobius/config", dstDir)
+ require.NoError(t, err)
+
+ // Check directory permissions
+ info, err := os.Stat(path.Join(dstDir, "Users"))
+ require.NoError(t, err)
+ assert.True(t, info.IsDir())
+
+ // Check that directory has reasonable permissions (at least readable/executable)
+ mode := info.Mode()
+ assert.True(t, mode&0400 != 0, "Directory should be readable")
+ assert.True(t, mode&0100 != 0, "Directory should be executable")
+}
+
+func TestFindConfigPath(t *testing.T) {
+ // Test function behavior by checking it returns one of the expected paths or fallback
+ t.Run("returns valid path", func(t *testing.T) {
+ result := findConfigPath()
+
+ // Should return either one of the search paths that exists, or "config" fallback
+ validPaths := append([]string{"config"}, mobius.ConfigSearchOrder...)
+
+ found := false
+ for _, validPath := range validPaths {
+ if result == validPath {
+ found = true
+ break
+ }
+ }
+
+ assert.True(t, found, "findConfigPath should return one of the valid paths or fallback, got: %s", result)
+ })
+
+ // Test directory vs file validation
+ t.Run("validates directory vs file", func(t *testing.T) {
+ // This test verifies the function logic but can't control system directories
+ // The function correctly validates that only directories are returned
+ result := findConfigPath()
+
+ // Verify result is an actual directory if it exists
+ if result != "config" {
+ info, err := os.Stat(result)
+ require.NoError(t, err, "Returned path should exist")
+ assert.True(t, info.IsDir(), "Returned path should be a directory")
+ }
+ })
+
+ // Test with existing directory
+ t.Run("finds existing directory", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ originalDir, err := os.Getwd()
+ require.NoError(t, err)
+ defer func() { _ = os.Chdir(originalDir) }()
+
+ err = os.Chdir(tmpDir)
+ require.NoError(t, err)
+
+ // Create a config directory
+ err = os.Mkdir("config", 0755)
+ require.NoError(t, err)
+
+ result := findConfigPath()
+ assert.Equal(t, "config", result)
+ })
+} \ No newline at end of file
diff --git a/cmd/mobius-hotline-server/mobius/config/config.yaml b/cmd/mobius-hotline-server/mobius/config/config.yaml
index 7cb7412..71b7e65 100644
--- a/cmd/mobius-hotline-server/mobius/config/config.yaml
+++ b/cmd/mobius-hotline-server/mobius/config/config.yaml
@@ -8,7 +8,7 @@ Description: A default configured Hotline server running Mobius
# * The banner must be under 256K (262,140 bytes specifically)
# * The standard size for a banner is 468 pixels wide and 60 pixels tall.
# * The banner must be saved in the same folder this file.
-# * The banner must be a jpg
+# * The banner must be a jpg or gif
BannerFile: "banner.jpg"
# Path to the Files directory, by default in a subdirectory of the config root named Files
diff --git a/docs/tls.md b/docs/tls.md
new file mode 100644
index 0000000..6cb2fd5
--- /dev/null
+++ b/docs/tls.md
@@ -0,0 +1,101 @@
+# TLS Support
+
+Mobius supports TLS (Transport Layer Security) for encrypted connections between clients and the server. When enabled, TLS runs on separate ports alongside the standard unencrypted ports, allowing both secure and legacy client connections simultaneously.
+
+## Ports
+
+| Service | Standard Port | TLS Port (default) |
+|---------------|---------------|-------------------|
+| Hotline | 5500 | 5600 |
+| File Transfer | 5501 | 5601 |
+
+## Generating Certificates
+
+### Self-Signed Certificate (Testing/Private Use)
+
+For testing or private servers, you can generate a self-signed certificate using OpenSSL:
+
+```bash
+openssl req -x509 -newkey rsa:4096 -keyout server.key -out server.crt -days 365 -nodes -subj "/CN=localhost"
+```
+
+This creates:
+- `server.key` - Private key file
+- `server.crt` - Certificate file
+
+For a certificate that includes your server's hostname or IP address:
+
+```bash
+openssl req -x509 -newkey rsa:4096 -keyout server.key -out server.crt -days 365 -nodes \
+ -subj "/CN=your-hostname.example.com" \
+ -addext "subjectAltName=DNS:your-hostname.example.com,IP:192.168.1.100"
+```
+
+### Let's Encrypt (Production)
+
+For production servers with a public domain name, use [Let's Encrypt](https://letsencrypt.org/) with certbot:
+
+```bash
+certbot certonly --standalone -d your-hostname.example.com
+```
+
+The certificates are typically stored at:
+- `/etc/letsencrypt/live/your-hostname.example.com/fullchain.pem`
+- `/etc/letsencrypt/live/your-hostname.example.com/privkey.pem`
+
+## Command-Line Options
+
+| Flag | Description | Default |
+|------|-------------|---------|
+| `-tls-cert` | Path to TLS certificate file | (none) |
+| `-tls-key` | Path to TLS private key file | (none) |
+| `-tls-port` | Base TLS port (file transfer uses base + 1) | 5600 |
+
+TLS is enabled when both `-tls-cert` and `-tls-key` are provided.
+
+## Usage Examples
+
+### Basic TLS Setup
+
+```bash
+mobius-hotline-server -tls-cert server.crt -tls-key server.key
+```
+
+### Custom TLS Port
+
+```bash
+mobius-hotline-server -tls-cert server.crt -tls-key server.key -tls-port 5700
+```
+
+### Full Example with All Options
+
+```bash
+mobius-hotline-server \
+ -config /path/to/config \
+ -bind 5500 \
+ -tls-cert /etc/letsencrypt/live/example.com/fullchain.pem \
+ -tls-key /etc/letsencrypt/live/example.com/privkey.pem \
+ -tls-port 5600
+```
+
+## Verifying TLS is Working
+
+When TLS is enabled, you'll see a log message at startup:
+
+```
+TLS enabled port=5600 fileTransferPort=5601
+```
+
+You can verify the TLS connection using OpenSSL:
+
+```bash
+openssl s_client -connect localhost:5600
+```
+
+## Client Configuration
+
+Clients connecting via TLS must:
+1. Connect to the TLS port (default 5600) instead of the standard port (5500)
+2. Support TLS connections (client-dependent)
+
+Note: Self-signed certificates may require clients to accept or trust the certificate manually.
diff --git a/go.mod b/go.mod
index 4bf4364..d5f1ef1 100644
--- a/go.mod
+++ b/go.mod
@@ -1,30 +1,33 @@
module github.com/jhalter/mobius
-go 1.23
+go 1.25.4
require (
- github.com/go-playground/validator/v10 v10.23.0
+ github.com/go-playground/validator/v10 v10.28.0
github.com/oleksandr/bonjour v0.0.0-20210301155756-30f43c61b915
- github.com/stretchr/testify v1.10.0
- golang.org/x/crypto v0.29.0
- golang.org/x/text v0.20.0
- golang.org/x/time v0.8.0
+ github.com/redis/go-redis/v9 v9.17.0
+ github.com/stretchr/testify v1.11.1
+ golang.org/x/crypto v0.45.0
+ golang.org/x/text v0.31.0
+ golang.org/x/time v0.14.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
)
require (
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
- github.com/gabriel-vasile/mimetype v1.4.7 // indirect
+ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
+ github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
- github.com/miekg/dns v1.1.62 // indirect
+ github.com/miekg/dns v1.1.68 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
- github.com/stretchr/objx v0.5.2 // indirect
- golang.org/x/mod v0.22.0 // indirect
- golang.org/x/net v0.31.0 // indirect
- golang.org/x/sync v0.9.0 // indirect
- golang.org/x/sys v0.27.0 // indirect
- golang.org/x/tools v0.27.0 // indirect
+ github.com/stretchr/objx v0.5.3 // indirect
+ golang.org/x/mod v0.30.0 // indirect
+ golang.org/x/net v0.47.0 // indirect
+ golang.org/x/sync v0.18.0 // indirect
+ golang.org/x/sys v0.38.0 // indirect
+ golang.org/x/tools v0.39.0 // indirect
)
diff --git a/go.sum b/go.sum
index 9f6a731..6a7632d 100644
--- a/go.sum
+++ b/go.sum
@@ -1,67 +1,81 @@
+github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
+github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
+github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
+github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
-github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
-github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
-github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
+github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
+github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
-github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
-github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
-github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
-github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
+github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
+github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
+github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
+github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
-github.com/miekg/dns v1.1.61 h1:nLxbwF3XxhwVSm8g9Dghm9MHPaUZuqhPiGL+675ZmEs=
-github.com/miekg/dns v1.1.61/go.mod h1:mnAarhS3nWaW+NVP2wTkYVIZyHNJ098SJZUki3eykwQ=
-github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
-github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
+github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
+github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
github.com/oleksandr/bonjour v0.0.0-20210301155756-30f43c61b915 h1:d291KOLbN1GthTPA1fLKyWdclX3k1ZP+CzYtun+a5Es=
github.com/oleksandr/bonjour v0.0.0-20210301155756-30f43c61b915/go.mod h1:MGuVJ1+5TX1SCoO2Sx0eAnjpdRytYla2uC1YIZfkC9c=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
-github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
-github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
-github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI=
+github.com/redis/go-redis/v9 v9.8.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
+github.com/redis/go-redis/v9 v9.17.0 h1:K6E+ZlYN95KSMmZeEQPbU/c++wfmEvfFB17yEAq/VhM=
+github.com/redis/go-redis/v9 v9.17.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
+github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
+github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
-golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
-golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
-golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
-golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
-golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
-golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
-golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
-golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
-golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
-golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
-golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
-golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
-golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
-golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
-golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
-golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
-golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
-golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
-golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
-golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
-golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
-golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
-golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
-golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
-golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
-golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
-golang.org/x/tools v0.27.0 h1:qEKojBykQkQ4EynWy4S8Weg69NumxKdn40Fce3uc/8o=
-golang.org/x/tools v0.27.0/go.mod h1:sUi0ZgbwW9ZPAq26Ekut+weQPR5eIM6GQLQ1Yjm1H0Q=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
+golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
+golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
+golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
+golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
+golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
+golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
+golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
+golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
+golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
+golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
+golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
+golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
+golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
+golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
+golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
+golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
+golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
+golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
+golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
+golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
+golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
+golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
+golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
+golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
+golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
+golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
+golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
+golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
+golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
+golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
+golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
diff --git a/hotline/access.go b/hotline/access.go
index 42e96c4..370f8c2 100644
--- a/hotline/access.go
+++ b/hotline/access.go
@@ -73,125 +73,53 @@ func (bits *AccessBitmap) UnmarshalYAML(unmarshal func(interface{}) error) error
case map[string]interface{}:
// Mobius versions >= v0.17.0 store the user access bitmap as map[string]bool to provide a human-readable view of
// the account permissions.
- if f, ok := v["DeleteFile"].(bool); ok && f {
- bits.Set(AccessDeleteFile)
+ accessMap := map[string]int{
+ "DeleteFile": AccessDeleteFile,
+ "UploadFile": AccessUploadFile,
+ "DownloadFile": AccessDownloadFile,
+ "RenameFile": AccessRenameFile,
+ "MoveFile": AccessMoveFile,
+ "CreateFolder": AccessCreateFolder,
+ "DeleteFolder": AccessDeleteFolder,
+ "RenameFolder": AccessRenameFolder,
+ "MoveFolder": AccessMoveFolder,
+ "ReadChat": AccessReadChat,
+ "SendChat": AccessSendChat,
+ "OpenChat": AccessOpenChat,
+ "CloseChat": AccessCloseChat,
+ "ShowInList": AccessShowInList,
+ "CreateUser": AccessCreateUser,
+ "DeleteUser": AccessDeleteUser,
+ "OpenUser": AccessOpenUser,
+ "ModifyUser": AccessModifyUser,
+ "ChangeOwnPass": AccessChangeOwnPass,
+ "NewsReadArt": AccessNewsReadArt,
+ "NewsPostArt": AccessNewsPostArt,
+ "DisconnectUser": AccessDisconUser,
+ "CannotBeDisconnected": AccessCannotBeDiscon,
+ "GetClientInfo": AccessGetClientInfo,
+ "UploadAnywhere": AccessUploadAnywhere,
+ "AnyName": AccessAnyName,
+ "NoAgreement": AccessNoAgreement,
+ "SetFileComment": AccessSetFileComment,
+ "SetFolderComment": AccessSetFolderComment,
+ "ViewDropBoxes": AccessViewDropBoxes,
+ "MakeAlias": AccessMakeAlias,
+ "Broadcast": AccessBroadcast,
+ "NewsDeleteArt": AccessNewsDeleteArt,
+ "NewsCreateCat": AccessNewsCreateCat,
+ "NewsDeleteCat": AccessNewsDeleteCat,
+ "NewsCreateFldr": AccessNewsCreateFldr,
+ "NewsDeleteFldr": AccessNewsDeleteFldr,
+ "SendPrivMsg": AccessSendPrivMsg,
+ "UploadFolder": AccessUploadFolder,
+ "DownloadFolder": AccessDownloadFolder,
}
- if f, ok := v["UploadFile"].(bool); ok && f {
- bits.Set(AccessUploadFile)
- }
- if f, ok := v["DownloadFile"].(bool); ok && f {
- bits.Set(AccessDownloadFile)
- }
- if f, ok := v["UploadFolder"].(bool); ok && f {
- bits.Set(AccessUploadFolder)
- }
- if f, ok := v["DownloadFolder"].(bool); ok && f {
- bits.Set(AccessDownloadFolder)
- }
- if f, ok := v["RenameFile"].(bool); ok && f {
- bits.Set(AccessRenameFile)
- }
- if f, ok := v["MoveFile"].(bool); ok && f {
- bits.Set(AccessMoveFile)
- }
- if f, ok := v["CreateFolder"].(bool); ok && f {
- bits.Set(AccessCreateFolder)
- }
- if f, ok := v["DeleteFolder"].(bool); ok && f {
- bits.Set(AccessDeleteFolder)
- }
- if f, ok := v["RenameFolder"].(bool); ok && f {
- bits.Set(AccessRenameFolder)
- }
- if f, ok := v["MoveFolder"].(bool); ok && f {
- bits.Set(AccessMoveFolder)
- }
- if f, ok := v["ReadChat"].(bool); ok && f {
- bits.Set(AccessReadChat)
- }
- if f, ok := v["SendChat"].(bool); ok && f {
- bits.Set(AccessSendChat)
- }
- if f, ok := v["OpenChat"].(bool); ok && f {
- bits.Set(AccessOpenChat)
- }
- if f, ok := v["CloseChat"].(bool); ok && f {
- bits.Set(AccessCloseChat)
- }
- if f, ok := v["ShowInList"].(bool); ok && f {
- bits.Set(AccessShowInList)
- }
- if f, ok := v["CreateUser"].(bool); ok && f {
- bits.Set(AccessCreateUser)
- }
- if f, ok := v["DeleteUser"].(bool); ok && f {
- bits.Set(AccessDeleteUser)
- }
- if f, ok := v["OpenUser"].(bool); ok && f {
- bits.Set(AccessOpenUser)
- }
- if f, ok := v["ModifyUser"].(bool); ok && f {
- bits.Set(AccessModifyUser)
- }
- if f, ok := v["ChangeOwnPass"].(bool); ok && f {
- bits.Set(AccessChangeOwnPass)
- }
- if f, ok := v["NewsReadArt"].(bool); ok && f {
- bits.Set(AccessNewsReadArt)
- }
- if f, ok := v["NewsPostArt"].(bool); ok && f {
- bits.Set(AccessNewsPostArt)
- }
- if f, ok := v["DisconnectUser"].(bool); ok && f {
- bits.Set(AccessDisconUser)
- }
- if f, ok := v["CannotBeDisconnected"].(bool); ok && f {
- bits.Set(AccessCannotBeDiscon)
- }
- if f, ok := v["GetClientInfo"].(bool); ok && f {
- bits.Set(AccessGetClientInfo)
- }
- if f, ok := v["UploadAnywhere"].(bool); ok && f {
- bits.Set(AccessUploadAnywhere)
- }
- if f, ok := v["AnyName"].(bool); ok && f {
- bits.Set(AccessAnyName)
- }
- if f, ok := v["NoAgreement"].(bool); ok && f {
- bits.Set(AccessNoAgreement)
- }
- if f, ok := v["SetFileComment"].(bool); ok && f {
- bits.Set(AccessSetFileComment)
- }
- if f, ok := v["SetFolderComment"].(bool); ok && f {
- bits.Set(AccessSetFolderComment)
- }
- if f, ok := v["ViewDropBoxes"].(bool); ok && f {
- bits.Set(AccessViewDropBoxes)
- }
- if f, ok := v["MakeAlias"].(bool); ok && f {
- bits.Set(AccessMakeAlias)
- }
- if f, ok := v["Broadcast"].(bool); ok && f {
- bits.Set(AccessBroadcast)
- }
- if f, ok := v["NewsDeleteArt"].(bool); ok && f {
- bits.Set(AccessNewsDeleteArt)
- }
- if f, ok := v["NewsCreateCat"].(bool); ok && f {
- bits.Set(AccessNewsCreateCat)
- }
- if f, ok := v["NewsDeleteCat"].(bool); ok && f {
- bits.Set(AccessNewsDeleteCat)
- }
- if f, ok := v["NewsCreateFldr"].(bool); ok && f {
- bits.Set(AccessNewsCreateFldr)
- }
- if f, ok := v["NewsDeleteFldr"].(bool); ok && f {
- bits.Set(AccessNewsDeleteFldr)
- }
- if f, ok := v["SendPrivMsg"].(bool); ok && f {
- bits.Set(AccessSendPrivMsg)
+
+ for key, accessBit := range accessMap {
+ if flag, ok := v[key].(bool); ok && flag {
+ bits.Set(accessBit)
+ }
}
}
diff --git a/hotline/access_test.go b/hotline/access_test.go
index f2755f3..fa2f6f6 100644
--- a/hotline/access_test.go
+++ b/hotline/access_test.go
@@ -2,6 +2,9 @@ package hotline
import (
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gopkg.in/yaml.v3"
+ "strings"
"testing"
)
@@ -37,3 +40,407 @@ func Test_accessBitmap_IsSet(t *testing.T) {
})
}
}
+
+func Test_accessBitmap_UnmarshalYAML(t *testing.T) {
+ // Test direct method call for explicit coverage
+ t.Run("direct method call", func(t *testing.T) {
+ var bits AccessBitmap
+ err := bits.UnmarshalYAML(func(v interface{}) error {
+ switch ptr := v.(type) {
+ case *interface{}:
+ *ptr = map[string]interface{}{
+ "DownloadFile": true,
+ "UploadFile": true,
+ }
+ }
+ return nil
+ })
+ require.NoError(t, err)
+ assert.True(t, bits.IsSet(AccessDownloadFile))
+ assert.True(t, bits.IsSet(AccessUploadFile))
+ })
+
+ // Original YAML unmarshaling tests
+ tests := []struct {
+ name string
+ yamlData string
+ expected AccessBitmap
+ wantErr bool
+ }{
+ {
+ name: "unmarshal array format (legacy)",
+ yamlData: "access: [96, 112, 12, 32, 3, 128, 0, 0]",
+ expected: AccessBitmap{96, 112, 12, 32, 3, 128, 0, 0},
+ wantErr: false,
+ },
+ {
+ name: "unmarshal map format with true values",
+ yamlData: `access:
+ DownloadFile: true
+ UploadFile: true
+ DeleteFile: true`,
+ expected: func() AccessBitmap {
+ var bits AccessBitmap
+ bits.Set(AccessDownloadFile)
+ bits.Set(AccessUploadFile)
+ bits.Set(AccessDeleteFile)
+ return bits
+ }(),
+ wantErr: false,
+ },
+ {
+ name: "unmarshal map format with false values",
+ yamlData: `access:
+ DownloadFile: false
+ UploadFile: false
+ DeleteFile: false`,
+ expected: AccessBitmap{},
+ wantErr: false,
+ },
+ {
+ name: "unmarshal map format with mixed values",
+ yamlData: `access:
+ DownloadFile: true
+ UploadFile: false
+ DeleteFile: true
+ CreateFolder: true`,
+ expected: func() AccessBitmap {
+ var bits AccessBitmap
+ bits.Set(AccessDownloadFile)
+ bits.Set(AccessDeleteFile)
+ bits.Set(AccessCreateFolder)
+ return bits
+ }(),
+ wantErr: false,
+ },
+ {
+ name: "unmarshal map format with all permissions",
+ yamlData: `access:
+ DeleteFile: true
+ UploadFile: true
+ DownloadFile: true
+ RenameFile: true
+ MoveFile: true
+ CreateFolder: true
+ DeleteFolder: true
+ RenameFolder: true
+ MoveFolder: true
+ ReadChat: true
+ SendChat: true
+ OpenChat: true
+ CloseChat: true
+ ShowInList: true
+ CreateUser: true
+ DeleteUser: true
+ OpenUser: true
+ ModifyUser: true
+ ChangeOwnPass: true
+ NewsReadArt: true
+ NewsPostArt: true
+ DisconnectUser: true
+ CannotBeDisconnected: true
+ GetClientInfo: true
+ UploadAnywhere: true
+ AnyName: true
+ NoAgreement: true
+ SetFileComment: true
+ SetFolderComment: true
+ ViewDropBoxes: true
+ MakeAlias: true
+ Broadcast: true
+ NewsDeleteArt: true
+ NewsCreateCat: true
+ NewsDeleteCat: true
+ NewsCreateFldr: true
+ NewsDeleteFldr: true
+ SendPrivMsg: true
+ UploadFolder: true
+ DownloadFolder: true`,
+ expected: func() AccessBitmap {
+ var bits AccessBitmap
+ bits.Set(AccessDeleteFile)
+ bits.Set(AccessUploadFile)
+ bits.Set(AccessDownloadFile)
+ bits.Set(AccessRenameFile)
+ bits.Set(AccessMoveFile)
+ bits.Set(AccessCreateFolder)
+ bits.Set(AccessDeleteFolder)
+ bits.Set(AccessRenameFolder)
+ bits.Set(AccessMoveFolder)
+ bits.Set(AccessReadChat)
+ bits.Set(AccessSendChat)
+ bits.Set(AccessOpenChat)
+ bits.Set(AccessCloseChat)
+ bits.Set(AccessShowInList)
+ bits.Set(AccessCreateUser)
+ bits.Set(AccessDeleteUser)
+ bits.Set(AccessOpenUser)
+ bits.Set(AccessModifyUser)
+ bits.Set(AccessChangeOwnPass)
+ bits.Set(AccessNewsReadArt)
+ bits.Set(AccessNewsPostArt)
+ bits.Set(AccessDisconUser)
+ bits.Set(AccessCannotBeDiscon)
+ bits.Set(AccessGetClientInfo)
+ bits.Set(AccessUploadAnywhere)
+ bits.Set(AccessAnyName)
+ bits.Set(AccessNoAgreement)
+ bits.Set(AccessSetFileComment)
+ bits.Set(AccessSetFolderComment)
+ bits.Set(AccessViewDropBoxes)
+ bits.Set(AccessMakeAlias)
+ bits.Set(AccessBroadcast)
+ bits.Set(AccessNewsDeleteArt)
+ bits.Set(AccessNewsCreateCat)
+ bits.Set(AccessNewsDeleteCat)
+ bits.Set(AccessNewsCreateFldr)
+ bits.Set(AccessNewsDeleteFldr)
+ bits.Set(AccessSendPrivMsg)
+ bits.Set(AccessUploadFolder)
+ bits.Set(AccessDownloadFolder)
+ return bits
+ }(),
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var data struct {
+ Access AccessBitmap `yaml:"access"`
+ }
+
+ err := yaml.Unmarshal([]byte(tt.yamlData), &data)
+
+ if tt.wantErr {
+ require.Error(t, err)
+ return
+ }
+
+ require.NoError(t, err)
+ assert.Equal(t, tt.expected, data.Access)
+ })
+ }
+}
+
+func Test_accessBitmap_MarshalYAML(t *testing.T) {
+ tests := []struct {
+ name string
+ bits AccessBitmap
+ expected string
+ }{
+ {
+ name: "empty access bitmap",
+ bits: AccessBitmap{},
+ expected: ` DownloadFile: false
+ DownloadFolder: false
+ UploadFile: false
+ UploadFolder: false
+ DeleteFile: false
+ RenameFile: false
+ MoveFile: false
+ CreateFolder: false
+ DeleteFolder: false
+ RenameFolder: false
+ MoveFolder: false
+ ReadChat: false
+ SendChat: false
+ OpenChat: false
+ CloseChat: false
+ ShowInList: false
+ CreateUser: false
+ DeleteUser: false
+ OpenUser: false
+ ModifyUser: false
+ ChangeOwnPass: false
+ NewsReadArt: false
+ NewsPostArt: false
+ DisconnectUser: false
+ CannotBeDisconnected: false
+ GetClientInfo: false
+ UploadAnywhere: false
+ AnyName: false
+ NoAgreement: false
+ SetFileComment: false
+ SetFolderComment: false
+ ViewDropBoxes: false
+ MakeAlias: false
+ Broadcast: false
+ NewsDeleteArt: false
+ NewsCreateCat: false
+ NewsDeleteCat: false
+ NewsCreateFldr: false
+ NewsDeleteFldr: false
+ SendPrivMsg: false
+`,
+ },
+ {
+ name: "access bitmap with some permissions",
+ bits: func() AccessBitmap {
+ var bits AccessBitmap
+ bits.Set(AccessDownloadFile)
+ bits.Set(AccessUploadFile)
+ bits.Set(AccessCreateFolder)
+ bits.Set(AccessReadChat)
+ return bits
+ }(),
+ expected: ` DownloadFile: true
+ DownloadFolder: false
+ UploadFile: true
+ UploadFolder: false
+ DeleteFile: false
+ RenameFile: false
+ MoveFile: false
+ CreateFolder: true
+ DeleteFolder: false
+ RenameFolder: false
+ MoveFolder: false
+ ReadChat: true
+ SendChat: false
+ OpenChat: false
+ CloseChat: false
+ ShowInList: false
+ CreateUser: false
+ DeleteUser: false
+ OpenUser: false
+ ModifyUser: false
+ ChangeOwnPass: false
+ NewsReadArt: false
+ NewsPostArt: false
+ DisconnectUser: false
+ CannotBeDisconnected: false
+ GetClientInfo: false
+ UploadAnywhere: false
+ AnyName: false
+ NoAgreement: false
+ SetFileComment: false
+ SetFolderComment: false
+ ViewDropBoxes: false
+ MakeAlias: false
+ Broadcast: false
+ NewsDeleteArt: false
+ NewsCreateCat: false
+ NewsDeleteCat: false
+ NewsCreateFldr: false
+ NewsDeleteFldr: false
+ SendPrivMsg: false
+`,
+ },
+ {
+ name: "access bitmap with all permissions",
+ bits: func() AccessBitmap {
+ var bits AccessBitmap
+ bits.Set(AccessDeleteFile)
+ bits.Set(AccessUploadFile)
+ bits.Set(AccessDownloadFile)
+ bits.Set(AccessRenameFile)
+ bits.Set(AccessMoveFile)
+ bits.Set(AccessCreateFolder)
+ bits.Set(AccessDeleteFolder)
+ bits.Set(AccessRenameFolder)
+ bits.Set(AccessMoveFolder)
+ bits.Set(AccessReadChat)
+ bits.Set(AccessSendChat)
+ bits.Set(AccessOpenChat)
+ bits.Set(AccessCloseChat)
+ bits.Set(AccessShowInList)
+ bits.Set(AccessCreateUser)
+ bits.Set(AccessDeleteUser)
+ bits.Set(AccessOpenUser)
+ bits.Set(AccessModifyUser)
+ bits.Set(AccessChangeOwnPass)
+ bits.Set(AccessNewsReadArt)
+ bits.Set(AccessNewsPostArt)
+ bits.Set(AccessDisconUser)
+ bits.Set(AccessCannotBeDiscon)
+ bits.Set(AccessGetClientInfo)
+ bits.Set(AccessUploadAnywhere)
+ bits.Set(AccessAnyName)
+ bits.Set(AccessNoAgreement)
+ bits.Set(AccessSetFileComment)
+ bits.Set(AccessSetFolderComment)
+ bits.Set(AccessViewDropBoxes)
+ bits.Set(AccessMakeAlias)
+ bits.Set(AccessBroadcast)
+ bits.Set(AccessNewsDeleteArt)
+ bits.Set(AccessNewsCreateCat)
+ bits.Set(AccessNewsDeleteCat)
+ bits.Set(AccessNewsCreateFldr)
+ bits.Set(AccessNewsDeleteFldr)
+ bits.Set(AccessSendPrivMsg)
+ bits.Set(AccessUploadFolder)
+ bits.Set(AccessDownloadFolder)
+ return bits
+ }(),
+ expected: ` DownloadFile: true
+ DownloadFolder: true
+ UploadFile: true
+ UploadFolder: true
+ DeleteFile: true
+ RenameFile: true
+ MoveFile: true
+ CreateFolder: true
+ DeleteFolder: true
+ RenameFolder: true
+ MoveFolder: true
+ ReadChat: true
+ SendChat: true
+ OpenChat: true
+ CloseChat: true
+ ShowInList: true
+ CreateUser: true
+ DeleteUser: true
+ OpenUser: true
+ ModifyUser: true
+ ChangeOwnPass: true
+ NewsReadArt: true
+ NewsPostArt: true
+ DisconnectUser: true
+ CannotBeDisconnected: true
+ GetClientInfo: true
+ UploadAnywhere: true
+ AnyName: true
+ NoAgreement: true
+ SetFileComment: true
+ SetFolderComment: true
+ ViewDropBoxes: true
+ MakeAlias: true
+ Broadcast: true
+ NewsDeleteArt: true
+ NewsCreateCat: true
+ NewsDeleteCat: true
+ NewsCreateFldr: true
+ NewsDeleteFldr: true
+ SendPrivMsg: true
+`,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Test via YAML marshaling
+ yamlData, err := yaml.Marshal(struct {
+ Access AccessBitmap `yaml:"access"`
+ }{Access: tt.bits})
+
+ require.NoError(t, err)
+
+ // Extract just the access portion
+ lines := strings.Split(string(yamlData), "\n")
+ var accessLines []string
+ inAccess := false
+ for _, line := range lines {
+ if strings.HasPrefix(line, "access:") {
+ inAccess = true
+ continue
+ }
+ if inAccess && strings.HasPrefix(line, " ") {
+ accessLines = append(accessLines, line)
+ }
+ }
+
+ actualAccess := strings.Join(accessLines, "\n") + "\n"
+ assert.Equal(t, tt.expected, actualAccess)
+ })
+ }
+}
diff --git a/hotline/account_manager.go b/hotline/account_manager.go
index 03621df..52c24f8 100644
--- a/hotline/account_manager.go
+++ b/hotline/account_manager.go
@@ -1,5 +1,7 @@
package hotline
+// AccountManager provides an interface for managing user accounts,
+// including creation, retrieval, updates, and deletion operations.
type AccountManager interface {
Create(account Account) error
Update(account Account, newLogin string) error
diff --git a/hotline/config.go b/hotline/config.go
index a7bd81e..75d5c4d 100644
--- a/hotline/config.go
+++ b/hotline/config.go
@@ -3,7 +3,7 @@ package hotline
type Config struct {
Name string `yaml:"Name" validate:"required,max=50"` // Name used for Tracker registration
Description string `yaml:"Description" validate:"required,max=200"` // Description used for Tracker registration
- BannerFile string `yaml:"BannerFile"` // Path to Banner jpg
+ BannerFile string `yaml:"BannerFile" validate:"omitempty,bannerext"` // Path to Banner jpg or gif
FileRoot string `yaml:"FileRoot" validate:"required"` // Path to Files
EnableTrackerRegistration bool `yaml:"EnableTrackerRegistration"` // Toggle Tracker Registration
Trackers []string `yaml:"Trackers" validate:"dive,hostname_port"` // List of trackers that the server should register with
diff --git a/hotline/field.go b/hotline/field.go
index 65ff394..1e6fc16 100644
--- a/hotline/field.go
+++ b/hotline/field.go
@@ -9,77 +9,80 @@ import (
"slices"
)
+// FieldType represents a Hotline protocol field type identifier
+type FieldType [2]byte
+
// List of Hotline protocol field types taken from the official 1.9 protocol document
var (
- FieldError = [2]byte{0x00, 0x64} // 100
- FieldData = [2]byte{0x00, 0x65} // 101
- FieldUserName = [2]byte{0x00, 0x66} // 102
- FieldUserID = [2]byte{0x00, 0x67} // 103
- FieldUserIconID = [2]byte{0x00, 0x68} // 104
- FieldUserLogin = [2]byte{0x00, 0x69} // 105
- FieldUserPassword = [2]byte{0x00, 0x6A} // 106
- FieldRefNum = [2]byte{0x00, 0x6B} // 107
- FieldTransferSize = [2]byte{0x00, 0x6C} // 108
- FieldChatOptions = [2]byte{0x00, 0x6D} // 109
- FieldUserAccess = [2]byte{0x00, 0x6E} // 110
- FieldUserFlags = [2]byte{0x00, 0x70} // 112
- FieldOptions = [2]byte{0x00, 0x71} // 113
- FieldChatID = [2]byte{0x00, 0x72} // 114
- FieldChatSubject = [2]byte{0x00, 0x73} // 115
- FieldWaitingCount = [2]byte{0x00, 0x74} // 116
- FieldBannerType = [2]byte{0x00, 0x98} // 152
- FieldNoServerAgreement = [2]byte{0x00, 0x98} // 152
- FieldVersion = [2]byte{0x00, 0xA0} // 160
- FieldCommunityBannerID = [2]byte{0x00, 0xA1} // 161
- FieldServerName = [2]byte{0x00, 0xA2} // 162
- FieldFileNameWithInfo = [2]byte{0x00, 0xC8} // 200
- FieldFileName = [2]byte{0x00, 0xC9} // 201
- FieldFilePath = [2]byte{0x00, 0xCA} // 202
- FieldFileResumeData = [2]byte{0x00, 0xCB} // 203
- FieldFileTransferOptions = [2]byte{0x00, 0xCC} // 204
- FieldFileTypeString = [2]byte{0x00, 0xCD} // 205
- FieldFileCreatorString = [2]byte{0x00, 0xCE} // 206
- FieldFileSize = [2]byte{0x00, 0xCF} // 207
- FieldFileCreateDate = [2]byte{0x00, 0xD0} // 208
- FieldFileModifyDate = [2]byte{0x00, 0xD1} // 209
- FieldFileComment = [2]byte{0x00, 0xD2} // 210
- FieldFileNewName = [2]byte{0x00, 0xD3} // 211
- FieldFileNewPath = [2]byte{0x00, 0xD4} // 212
- FieldFileType = [2]byte{0x00, 0xD5} // 213
- FieldQuotingMsg = [2]byte{0x00, 0xD6} // 214
- FieldAutomaticResponse = [2]byte{0x00, 0xD7} // 215
- FieldFolderItemCount = [2]byte{0x00, 0xDC} // 220
- FieldUsernameWithInfo = [2]byte{0x01, 0x2C} // 300
- FieldNewsArtListData = [2]byte{0x01, 0x41} // 321
- FieldNewsCatName = [2]byte{0x01, 0x42} // 322
- FieldNewsCatListData15 = [2]byte{0x01, 0x43} // 323
- FieldNewsPath = [2]byte{0x01, 0x45} // 325
- FieldNewsArtID = [2]byte{0x01, 0x46} // 326
- FieldNewsArtDataFlav = [2]byte{0x01, 0x47} // 327
- FieldNewsArtTitle = [2]byte{0x01, 0x48} // 328
- FieldNewsArtPoster = [2]byte{0x01, 0x49} // 329
- FieldNewsArtDate = [2]byte{0x01, 0x4A} // 330
- FieldNewsArtPrevArt = [2]byte{0x01, 0x4B} // 331
- FieldNewsArtNextArt = [2]byte{0x01, 0x4C} // 332
- FieldNewsArtData = [2]byte{0x01, 0x4D} // 333
- FieldNewsArtParentArt = [2]byte{0x01, 0x4F} // 335
- FieldNewsArt1stChildArt = [2]byte{0x01, 0x50} // 336
- FieldNewsArtRecurseDel = [2]byte{0x01, 0x51} // 337
+ FieldError = FieldType{0x00, 0x64} // 100
+ FieldData = FieldType{0x00, 0x65} // 101
+ FieldUserName = FieldType{0x00, 0x66} // 102
+ FieldUserID = FieldType{0x00, 0x67} // 103
+ FieldUserIconID = FieldType{0x00, 0x68} // 104
+ FieldUserLogin = FieldType{0x00, 0x69} // 105
+ FieldUserPassword = FieldType{0x00, 0x6A} // 106
+ FieldRefNum = FieldType{0x00, 0x6B} // 107
+ FieldTransferSize = FieldType{0x00, 0x6C} // 108
+ FieldChatOptions = FieldType{0x00, 0x6D} // 109
+ FieldUserAccess = FieldType{0x00, 0x6E} // 110
+ FieldUserFlags = FieldType{0x00, 0x70} // 112
+ FieldOptions = FieldType{0x00, 0x71} // 113
+ FieldChatID = FieldType{0x00, 0x72} // 114
+ FieldChatSubject = FieldType{0x00, 0x73} // 115
+ FieldWaitingCount = FieldType{0x00, 0x74} // 116
+ FieldBannerType = FieldType{0x00, 0x98} // 152
+ FieldNoServerAgreement = FieldType{0x00, 0x98} // 152
+ FieldVersion = FieldType{0x00, 0xA0} // 160
+ FieldCommunityBannerID = FieldType{0x00, 0xA1} // 161
+ FieldServerName = FieldType{0x00, 0xA2} // 162
+ FieldFileNameWithInfo = FieldType{0x00, 0xC8} // 200
+ FieldFileName = FieldType{0x00, 0xC9} // 201
+ FieldFilePath = FieldType{0x00, 0xCA} // 202
+ FieldFileResumeData = FieldType{0x00, 0xCB} // 203
+ FieldFileTransferOptions = FieldType{0x00, 0xCC} // 204
+ FieldFileTypeString = FieldType{0x00, 0xCD} // 205
+ FieldFileCreatorString = FieldType{0x00, 0xCE} // 206
+ FieldFileSize = FieldType{0x00, 0xCF} // 207
+ FieldFileCreateDate = FieldType{0x00, 0xD0} // 208
+ FieldFileModifyDate = FieldType{0x00, 0xD1} // 209
+ FieldFileComment = FieldType{0x00, 0xD2} // 210
+ FieldFileNewName = FieldType{0x00, 0xD3} // 211
+ FieldFileNewPath = FieldType{0x00, 0xD4} // 212
+ FieldFileType = FieldType{0x00, 0xD5} // 213
+ FieldQuotingMsg = FieldType{0x00, 0xD6} // 214
+ FieldAutomaticResponse = FieldType{0x00, 0xD7} // 215
+ FieldFolderItemCount = FieldType{0x00, 0xDC} // 220
+ FieldUsernameWithInfo = FieldType{0x01, 0x2C} // 300
+ FieldNewsArtListData = FieldType{0x01, 0x41} // 321
+ FieldNewsCatName = FieldType{0x01, 0x42} // 322
+ FieldNewsCatListData15 = FieldType{0x01, 0x43} // 323
+ FieldNewsPath = FieldType{0x01, 0x45} // 325
+ FieldNewsArtID = FieldType{0x01, 0x46} // 326
+ FieldNewsArtDataFlav = FieldType{0x01, 0x47} // 327
+ FieldNewsArtTitle = FieldType{0x01, 0x48} // 328
+ FieldNewsArtPoster = FieldType{0x01, 0x49} // 329
+ FieldNewsArtDate = FieldType{0x01, 0x4A} // 330
+ FieldNewsArtPrevArt = FieldType{0x01, 0x4B} // 331
+ FieldNewsArtNextArt = FieldType{0x01, 0x4C} // 332
+ FieldNewsArtData = FieldType{0x01, 0x4D} // 333
+ FieldNewsArtParentArt = FieldType{0x01, 0x4F} // 335
+ FieldNewsArt1stChildArt = FieldType{0x01, 0x50} // 336
+ FieldNewsArtRecurseDel = FieldType{0x01, 0x51} // 337
// These fields are documented, but seemingly unused.
- // FieldUserAlias = [2]byte{0x00, 0x6F} // 111
- // FieldNewsArtFlags = [2]byte{0x01, 0x4E} // 334
+ // FieldUserAlias = FieldType{0x00, 0x6F} // 111
+ // FieldNewsArtFlags = FieldType{0x01, 0x4E} // 334
)
type Field struct {
- Type [2]byte // Type of field
- FieldSize [2]byte // Size of the data field
- Data []byte // Field data
+ Type FieldType // Type of field
+ FieldSize [2]byte // Size of the data field
+ Data []byte // Field data
readOffset int // Internal offset to track read progress
}
-func NewField(fieldType [2]byte, data []byte) Field {
+func NewField(fieldType FieldType, data []byte) Field {
f := Field{
Type: fieldType,
Data: make([]byte, len(data)),
@@ -185,7 +188,7 @@ func (f *Field) Write(p []byte) (int, error) {
return minFieldLen + dataSize, nil
}
-func GetField(id [2]byte, fields *[]Field) *Field {
+func GetField(id FieldType, fields *[]Field) *Field {
for _, field := range *fields {
if id == field.Type {
return &field
diff --git a/hotline/field_test.go b/hotline/field_test.go
index 213d9c3..3440b0d 100644
--- a/hotline/field_test.go
+++ b/hotline/field_test.go
@@ -102,7 +102,7 @@ func Test_fieldScanner(t *testing.T) {
func TestField_Read(t *testing.T) {
type fields struct {
- ID [2]byte
+ Type FieldType
FieldSize [2]byte
Data []byte
readOffset int
@@ -121,7 +121,7 @@ func TestField_Read(t *testing.T) {
{
name: "returns field bytes",
fields: fields{
- ID: [2]byte{0x00, 0x62},
+ Type: FieldType{0x00, 0x62},
FieldSize: [2]byte{0x00, 0x03},
Data: []byte("hai!"),
},
@@ -139,7 +139,7 @@ func TestField_Read(t *testing.T) {
{
name: "returns field bytes from readOffset",
fields: fields{
- ID: [2]byte{0x00, 0x62},
+ Type: FieldType{0x00, 0x62},
FieldSize: [2]byte{0x00, 0x03},
Data: []byte("hai!"),
readOffset: 4,
@@ -156,7 +156,7 @@ func TestField_Read(t *testing.T) {
{
name: "returns io.EOF when all bytes read",
fields: fields{
- ID: [2]byte{0x00, 0x62},
+ Type: FieldType{0x00, 0x62},
FieldSize: [2]byte{0x00, 0x03},
Data: []byte("hai!"),
readOffset: 8,
@@ -172,7 +172,7 @@ func TestField_Read(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := &Field{
- Type: tt.fields.ID,
+ Type: tt.fields.Type,
FieldSize: tt.fields.FieldSize,
Data: tt.fields.Data,
readOffset: tt.fields.readOffset,
@@ -229,3 +229,70 @@ func TestField_DecodeInt(t *testing.T) {
})
}
}
+
+func TestField_DecodeNewsPath(t *testing.T) {
+ type fields struct {
+ Data []byte
+ }
+ tests := []struct {
+ name string
+ fields fields
+ want []string
+ wantErr assert.ErrorAssertionFunc
+ }{
+ {
+ name: "empty field data",
+ fields: fields{Data: []byte{}},
+ want: []string{},
+ wantErr: assert.NoError,
+ },
+ {
+ name: "single path",
+ fields: fields{Data: []byte{
+ 0x00, 0x01, // path count = 1
+ 0x00, 0x00, 0x05, // 2 bytes unused + 1 byte length (5)
+ 0x48, 0x65, 0x6c, 0x6c, 0x6f, // "Hello"
+ }},
+ want: []string{"Hello"},
+ wantErr: assert.NoError,
+ },
+ {
+ name: "multiple paths",
+ fields: fields{Data: []byte{
+ 0x00, 0x02, // path count = 2
+ 0x00, 0x00, 0x05, // 2 bytes unused + 1 byte length (5)
+ 0x48, 0x65, 0x6c, 0x6c, 0x6f, // "Hello"
+ 0x00, 0x00, 0x05, // 2 bytes unused + 1 byte length (5)
+ 0x57, 0x6f, 0x72, 0x6c, 0x64, // "World"
+ }},
+ want: []string{"Hello", "World"},
+ wantErr: assert.NoError,
+ },
+ {
+ name: "example from comments - nested categories",
+ fields: fields{Data: []byte{
+ 0x00, 0x03, // path count = 3
+ 0x00, 0x00, 0x10, // 2 bytes unused + 1 byte length (16)
+ 0x54, 0x6f, 0x70, 0x20, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x20, 0x42, 0x75, 0x6e, 0x64, 0x6c, 0x65, // "Top Level Bundle"
+ 0x00, 0x00, 0x13, // 2 bytes unused + 1 byte length (19)
+ 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x20, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x20, 0x42, 0x75, 0x6e, 0x64, 0x6c, 0x65, // "Second Level Bundle"
+ 0x00, 0x00, 0x0f, // 2 bytes unused + 1 byte length (15)
+ 0x4e, 0x65, 0x73, 0x74, 0x65, 0x64, 0x20, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, // "Nested Category"
+ }},
+ want: []string{"Top Level Bundle", "Second Level Bundle", "Nested Category"},
+ wantErr: assert.NoError,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ f := &Field{
+ Data: tt.fields.Data,
+ }
+ got, err := f.DecodeNewsPath()
+ if !tt.wantErr(t, err, "DecodeNewsPath()") {
+ return
+ }
+ assert.Equalf(t, tt.want, got, "DecodeNewsPath()")
+ })
+ }
+}
diff --git a/hotline/file_path.go b/hotline/file_path.go
index bb6bbd3..aba3195 100644
--- a/hotline/file_path.go
+++ b/hotline/file_path.go
@@ -7,7 +7,7 @@ import (
"errors"
"fmt"
"io"
- "path/filepath"
+ "path"
"strings"
)
@@ -121,13 +121,13 @@ func ReadPath(fileRoot string, filePath, fileName []byte) (fullPath string, err
var subPath string
for _, pathItem := range fp.Items {
- subPath = filepath.Join("/", subPath, string(pathItem.Name))
+ subPath = path.Join("/", subPath, string(pathItem.Name))
}
- fullPath = filepath.Join(
+ fullPath = path.Join(
fileRoot,
subPath,
- filepath.Join("/", string(fileName)),
+ path.Join("/", string(fileName)),
)
fullPath, err = txtDecoder.String(fullPath)
if err != nil {
diff --git a/hotline/file_resume_data.go b/hotline/file_resume_data.go
index 5dfd2d8..1926bc6 100644
--- a/hotline/file_resume_data.go
+++ b/hotline/file_resume_data.go
@@ -3,38 +3,59 @@ package hotline
import (
"bytes"
"encoding/binary"
+ "fmt"
)
// FileResumeData is sent when a client or server would like to resume a transfer from an offset
type FileResumeData struct {
Format [4]byte // "RFLT"
Version [2]byte // Always 1
- RSVD [34]byte // Unused
+ RSVD [34]byte // Present in the Hotline protocol docs, but unused. Left here for documentation purposes.
ForkCount [2]byte // Length of ForkInfoList. Either 2 or 3 depending on whether file has a resource fork
ForkInfoList []ForkInfoList
+}
+
+// ForkType represents a 4-byte fork type identifier
+type ForkType [4]byte
- //readOffset int // TODO
+// String returns a string representation of the fork type
+func (ft ForkType) String() string {
+ return fmt.Sprintf("%c%c%c%c", ft[0], ft[1], ft[2], ft[3])
}
type ForkInfoList struct {
- Fork [4]byte // "DATA" or "MACR"
- DataSize [4]byte // offset from which to resume the transfer of data
- RSVDA [4]byte // Unused
- RSVDB [4]byte // Unused
+ Fork ForkType // "DATA", "INFO", or "MACR"
+ DataSize [4]byte // offset from which to resume the transfer of data
+ RSVDA [4]byte // Present in the Hotline protocol docs, but unused. Left here for documentation purposes.
+ RSVDB [4]byte // Present in the Hotline protocol docs, but unused. Left here for documentation purposes.
}
+var (
+ ForkTypeDATA = ForkType{0x44, 0x41, 0x54, 0x41} // DATA: Data fork
+ ForkTypeINFO = ForkType{0x49, 0x4E, 0x46, 0x4F} // INFO: Information fork
+ ForkTypeMACR = ForkType{0x4d, 0x41, 0x43, 0x52} // MACR: Mac resource fork
+)
+
func NewForkInfoList(b []byte) *ForkInfoList {
return &ForkInfoList{
- Fork: [4]byte{0x44, 0x41, 0x54, 0x41},
+ Fork: ForkTypeDATA,
DataSize: [4]byte{b[0], b[1], b[2], b[3]},
RSVDA: [4]byte{},
RSVDB: [4]byte{},
}
}
+var (
+ FormatFILP = [4]byte{0x46, 0x49, 0x4c, 0x50} // Flattened file format: "FILP"
+ FormatRFLT = [4]byte{0x52, 0x46, 0x4C, 0x54} // File resume format: "RFLT" (?)
+
+ PlatformAMAC = [4]byte{0x41, 0x4D, 0x41, 0x43} // Mac platform: "AMAC"
+ PlatformMWIN = [4]byte{0x4D, 0x57, 0x49, 0x4E} // Windows platform: "MWIN"
+)
+
func NewFileResumeData(list []ForkInfoList) *FileResumeData {
return &FileResumeData{
- Format: [4]byte{0x52, 0x46, 0x4C, 0x54}, // RFLT
+ Format: FormatRFLT,
Version: [2]byte{0, 1},
RSVD: [34]byte{},
ForkCount: [2]byte{0, uint8(len(list))},
@@ -42,31 +63,6 @@ func NewFileResumeData(list []ForkInfoList) *FileResumeData {
}
}
-//
-//func (frd *FileResumeData) Read(p []byte) (int, error) {
-// buf := slices.Concat(
-// frd.Format[:],
-// frd.Version[:],
-// frd.RSVD[:],
-// frd.ForkCount[:],
-// )
-// for _, fil := range frd.ForkInfoList {
-// buf = append(buf, fil...)
-// _ = binary.Write(&buf, binary.LittleEndian, fil)
-// }
-//
-// var buf bytes.Buffer
-// _ = binary.Write(&buf, binary.LittleEndian, frd.Format)
-// _ = binary.Write(&buf, binary.LittleEndian, frd.Version)
-// _ = binary.Write(&buf, binary.LittleEndian, frd.RSVD)
-// _ = binary.Write(&buf, binary.LittleEndian, frd.ForkCount)
-// for _, fil := range frd.ForkInfoList {
-// _ = binary.Write(&buf, binary.LittleEndian, fil)
-// }
-//
-// return buf.Bytes(), nil
-//}
-
func (frd *FileResumeData) BinaryMarshal() ([]byte, error) {
var buf bytes.Buffer
_ = binary.Write(&buf, binary.LittleEndian, frd.Format)
diff --git a/hotline/file_transfer.go b/hotline/file_transfer.go
index 4e71bd6..386b24a 100644
--- a/hotline/file_transfer.go
+++ b/hotline/file_transfer.go
@@ -2,6 +2,7 @@ package hotline
import (
"bufio"
+ "bytes"
"crypto/rand"
"encoding/binary"
"errors"
@@ -11,6 +12,7 @@ import (
"log/slog"
"math"
"os"
+ "path"
"path/filepath"
"slices"
"strings"
@@ -170,37 +172,44 @@ type folderUpload struct {
FileNamePath []byte
}
-//func (fu *folderUpload) Write(p []byte) (int, error) {
-// if len(p) < 7 {
-// return 0, errors.New("buflen too short")
-// }
-// copy(fu.DataSize[:], p[0:2])
-// copy(fu.IsFolder[:], p[2:4])
-// copy(fu.PathItemCount[:], p[4:6])
-//
-// fu.FileNamePath = make([]byte, binary.BigEndian.Uint16(fu.DataSize[:])-4) // -4 to subtract the path separator bytes TODO: wat
-// n, err := io.ReadFull(rwc, fu.FileNamePath)
-// if err != nil {
-// return 0, err
-// }
-//
-// return n + 6, nil
-//}
+// pathSegmentScanner implements bufio.SplitFunc for parsing path segments
+func pathSegmentScanner(data []byte, _ bool) (advance int, token []byte, err error) {
+ if len(data) < 3 {
+ return 0, nil, nil
+ }
+
+ segLen := int(data[2])
+ totalLen := 3 + segLen
+
+ if len(data) < totalLen {
+ return 0, nil, nil
+ }
+
+ return totalLen, data[0:totalLen], nil
+}
func (fu *folderUpload) FormattedPath() string {
pathItemLen := binary.BigEndian.Uint16(fu.PathItemCount[:])
+ if pathItemLen == 0 {
+ return ""
+ }
+
var pathSegments []string
- pathData := fu.FileNamePath
+ scanner := bufio.NewScanner(bytes.NewReader(fu.FileNamePath))
+ scanner.Split(pathSegmentScanner)
- // TODO: implement scanner interface instead?
- for i := uint16(0); i < pathItemLen; i++ {
- segLen := pathData[2]
- pathSegments = append(pathSegments, string(pathData[3:3+segLen]))
- pathData = pathData[3+segLen:]
+ for scanner.Scan() && len(pathSegments) < int(pathItemLen) {
+ segmentData := scanner.Bytes()
+ if len(segmentData) >= 3 {
+ segLen := int(segmentData[2])
+ if len(segmentData) >= 3+segLen {
+ pathSegments = append(pathSegments, string(segmentData[3:3+segLen]))
+ }
+ }
}
- return filepath.Join(pathSegments...)
+ return path.Join(pathSegments...)
}
type FileHeader struct {
@@ -243,18 +252,12 @@ func (fh *FileHeader) Read(p []byte) (int, error) {
}
func DownloadHandler(w io.Writer, fullPath string, fileTransfer *FileTransfer, fs FileStore, rLogger *slog.Logger, preserveForks bool) error {
- //s.Stats.DownloadCounter += 1
- //s.Stats.DownloadsInProgress += 1
- //defer func() {
- // s.Stats.DownloadsInProgress -= 1
- //}()
-
var dataOffset int64
if fileTransfer.FileResumeData != nil {
dataOffset = int64(binary.BigEndian.Uint32(fileTransfer.FileResumeData.ForkInfoList[0].DataSize[:]))
}
- fw, err := NewFileWrapper(fs, fullPath, 0)
+ hlFile, err := NewFile(fs, fullPath, 0)
if err != nil {
return fmt.Errorf("reading file header: %v", err)
}
@@ -264,12 +267,12 @@ func DownloadHandler(w io.Writer, fullPath string, fileTransfer *FileTransfer, f
// If file transfer options are included, that means this is a "quick preview" request. In this case skip sending
// the flat file info and proceed directly to sending the file data.
if fileTransfer.Options == nil {
- if _, err = io.Copy(w, fw.Ffo); err != nil {
+ if _, err = io.Copy(w, hlFile.Ffo); err != nil {
return fmt.Errorf("send flat file object: %v", err)
}
}
- file, err := fw.dataForkReader()
+ file, err := hlFile.dataForkReader()
if err != nil {
return fmt.Errorf("open data fork reader: %v", err)
}
@@ -285,13 +288,13 @@ func DownloadHandler(w io.Writer, fullPath string, fileTransfer *FileTransfer, f
// If the client requested to resume transfer, do not send the resource fork header.
if fileTransfer.FileResumeData == nil {
- err = binary.Write(w, binary.BigEndian, fw.rsrcForkHeader())
+ err = binary.Write(w, binary.BigEndian, hlFile.rsrcForkHeader())
if err != nil {
return fmt.Errorf("send resource fork header: %v", err)
}
}
- rFile, _ := fw.rsrcForkFile()
+ rFile, _ := hlFile.rsrcForkFile()
//if err != nil {
// // return fmt.Errorf("open resource fork file: %v", err)
//}
@@ -318,17 +321,18 @@ func UploadHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileTransfe
if err == nil {
// return fmt.Errorf("existing file found: %s", fullPath)
}
- // if errors.Is(err, fs.ErrNotExist) {
- // If not found, open or create a new .incomplete file
- file, err = os.OpenFile(fullPath+IncompleteFileSuffix, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
- if err != nil {
- return err
- }
- // }
- defer file.Close()
+ if !errors.Is(err, fs.ErrNotExist) {
+ return fmt.Errorf("check file existence: %w", err)
+ }
+
+ // If not found, open or create a new .incomplete file
+ file, err = os.OpenFile(fullPath+IncompleteFileSuffix, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
+ if err != nil {
+ return fmt.Errorf("open temp file for uploade: %w", err)
+ }
- f, err := NewFileWrapper(fileStore, fullPath, 0)
+ f, err := NewFile(fileStore, fullPath, 0)
if err != nil {
return err
}
@@ -350,9 +354,16 @@ func UploadHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileTransfe
}
if err := receiveFile(rwc, file, rForkWriter, iForkWriter, fileTransfer.bytesSentCounter); err != nil {
+ _ = file.Close() // Close on error
return fmt.Errorf("receive file: %v", err)
}
+ // Close the file before attempting to rename it.
+ if err := file.Close(); err != nil {
+ return fmt.Errorf("close file: %v", err)
+ }
+
+ // Rename the temporary upload file to the final file name.
if err := fileStore.Rename(fullPath+".incomplete", fullPath); err != nil {
return fmt.Errorf("rename incomplete file: %v", err)
}
@@ -413,7 +424,7 @@ func DownloadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *Fil
return nil
}
- hlFile, err := NewFileWrapper(fileStore, path, 0)
+ hlFile, err := NewFile(fileStore, path, 0)
if err != nil {
return err
}
@@ -486,7 +497,6 @@ func DownloadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *Fil
return fmt.Errorf("error opening file: %w", err)
}
- // wr := bufio.NewWriterSize(rwc, 1460)
if _, err = io.Copy(rwc, io.TeeReader(file, fileTransfer.bytesSentCounter)); err != nil {
return fmt.Errorf("error sending file: %w", err)
}
@@ -542,7 +552,6 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT
//s.Stats.UploadCounter += 1
var fu folderUpload
- // TODO: implement io.Writer on folderUpload and replace this
if _, err := io.ReadFull(rwc, fu.DataSize[:]); err != nil {
return err
}
@@ -552,14 +561,14 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT
if _, err := io.ReadFull(rwc, fu.PathItemCount[:]); err != nil {
return err
}
- fu.FileNamePath = make([]byte, binary.BigEndian.Uint16(fu.DataSize[:])-4) // -4 to subtract the path separator bytes TODO: wat
+ fu.FileNamePath = make([]byte, binary.BigEndian.Uint16(fu.DataSize[:])-4) // -4 to subtract the length of the DataSize and IsFolder fields
if _, err := io.ReadFull(rwc, fu.FileNamePath); err != nil {
return err
}
if fu.IsFolder == [2]byte{0, 1} {
- if _, err := os.Stat(filepath.Join(fullPath, fu.FormattedPath())); os.IsNotExist(err) {
- if err := os.Mkdir(filepath.Join(fullPath, fu.FormattedPath()), 0777); err != nil {
+ if _, err := os.Stat(path.Join(fullPath, fu.FormattedPath())); os.IsNotExist(err) {
+ if err := os.Mkdir(path.Join(fullPath, fu.FormattedPath()), 0777); err != nil {
return err
}
}
@@ -572,7 +581,7 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT
nextAction := DlFldrActionSendFile
// Check if we have the full file already. If so, send dlFldrAction_NextFile to client to skip.
- _, err := os.Stat(filepath.Join(fullPath, fu.FormattedPath()))
+ _, err := os.Stat(path.Join(fullPath, fu.FormattedPath()))
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
@@ -581,7 +590,7 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT
}
// Check if we have a partial file already. If so, send dlFldrAction_ResumeFile to client to resume upload.
- incompleteFile, err := os.Stat(filepath.Join(fullPath, fu.FormattedPath()+IncompleteFileSuffix))
+ incompleteFile, err := os.Stat(path.Join(fullPath, fu.FormattedPath()+IncompleteFileSuffix))
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
@@ -634,9 +643,9 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT
return err
}
- filePath := filepath.Join(fullPath, fu.FormattedPath())
+ filePath := path.Join(fullPath, fu.FormattedPath())
- hlFile, err := NewFileWrapper(fileStore, filePath, 0)
+ hlFile, err := NewFile(fileStore, filePath, 0)
if err != nil {
return err
}
@@ -665,6 +674,12 @@ func UploadFolderHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileT
return err
}
+ // Close the file before attempting to rename it.
+ if err := incWriter.Close(); err != nil {
+ return fmt.Errorf("close file: %v", err)
+ }
+
+ // Rename the temporary upload file to the final file name.
if err := os.Rename(filePath+".incomplete", filePath); err != nil {
return err
}
diff --git a/hotline/file_transfer_test.go b/hotline/file_transfer_test.go
index 0b549f8..ba29910 100644
--- a/hotline/file_transfer_test.go
+++ b/hotline/file_transfer_test.go
@@ -175,3 +175,94 @@ func TestFileHeader_Payload(t *testing.T) {
})
}
}
+
+func Test_folderUpload_FormattedPath(t *testing.T) {
+ tests := []struct {
+ name string
+ pathItemCount [2]byte
+ fileNamePath []byte
+ want string
+ }{
+ {
+ name: "empty path",
+ pathItemCount: [2]byte{0x00, 0x00},
+ fileNamePath: []byte{},
+ want: "",
+ },
+ {
+ name: "single path segment",
+ pathItemCount: [2]byte{0x00, 0x01},
+ fileNamePath: []byte{
+ 0x00, 0x00, // path separator
+ 0x03, // segment length
+ 0x66, 0x6f, 0x6f, // "foo"
+ },
+ want: "foo",
+ },
+ {
+ name: "multiple path segments",
+ pathItemCount: [2]byte{0x00, 0x03},
+ fileNamePath: []byte{
+ 0x00, 0x00, // path separator
+ 0x04, // segment length
+ 0x68, 0x6f, 0x6d, 0x65, // "home"
+ 0x00, 0x00, // path separator
+ 0x04, // segment length
+ 0x75, 0x73, 0x65, 0x72, // "user"
+ 0x00, 0x00, // path separator
+ 0x09, // segment length
+ 0x64, 0x6f, 0x63, 0x75, 0x6d, 0x65, 0x6e, 0x74, 0x73, // "documents"
+ },
+ want: "home/user/documents",
+ },
+ {
+ name: "path with spaces",
+ pathItemCount: [2]byte{0x00, 0x02},
+ fileNamePath: []byte{
+ 0x00, 0x00, // path separator
+ 0x07, // segment length
+ 0x4d, 0x79, 0x20, 0x46, 0x69, 0x6c, 0x65, // "My File"
+ 0x00, 0x00, // path separator
+ 0x0d, // segment length (13 bytes)
+ 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x61, 0x6e, 0x74, 0x2e, 0x74, 0x78, 0x74, // "Important.txt"
+ },
+ want: "My File/Important.txt",
+ },
+ {
+ name: "single character segments",
+ pathItemCount: [2]byte{0x00, 0x03},
+ fileNamePath: []byte{
+ 0x00, 0x00, // path separator
+ 0x01, // segment length
+ 0x61, // "a"
+ 0x00, 0x00, // path separator
+ 0x01, // segment length
+ 0x62, // "b"
+ 0x00, 0x00, // path separator
+ 0x01, // segment length
+ 0x63, // "c"
+ },
+ want: "a/b/c",
+ },
+ {
+ name: "path with special characters",
+ pathItemCount: [2]byte{0x00, 0x01},
+ fileNamePath: []byte{
+ 0x00, 0x00, // path separator
+ 0x08, // segment length
+ 0x74, 0x65, 0x73, 0x74, 0x40, 0x24, 0x25, 0x26, // "test@$%&"
+ },
+ want: "test@$%&",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ fu := &folderUpload{
+ PathItemCount: tt.pathItemCount,
+ FileNamePath: tt.fileNamePath,
+ }
+ got := fu.FormattedPath()
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
diff --git a/hotline/file_wrapper.go b/hotline/file_wrapper.go
index b13e0dc..b80bc4b 100644
--- a/hotline/file_wrapper.go
+++ b/hotline/file_wrapper.go
@@ -17,8 +17,8 @@ const (
RsrcForkNameTemplate = ".rsrc_%s" // template string for resource fork filenames
)
-// fileWrapper encapsulates the data, info, and resource forks of a Hotline file and provides methods to manage the files.
-type fileWrapper struct {
+// File encapsulates the data, info, and resource forks of a Hotline file and provides methods to manage the files.
+type File struct {
fs FileStore
Name string // Name of the file
path string // path to file directory
@@ -30,10 +30,10 @@ type fileWrapper struct {
Ffo *flattenedFileObject
}
-func NewFileWrapper(fs FileStore, path string, dataOffset int64) (*fileWrapper, error) {
+func NewFile(fs FileStore, path string, dataOffset int64) (*File, error) {
dir := filepath.Dir(path)
fName := filepath.Base(path)
- f := fileWrapper{
+ f := File{
fs: fs,
Name: fName,
path: dir,
@@ -54,7 +54,7 @@ func NewFileWrapper(fs FileStore, path string, dataOffset int64) (*fileWrapper,
return &f, nil
}
-func (f *fileWrapper) TotalSize() []byte {
+func (f *File) TotalSize() []byte {
var s int64
size := make([]byte, 4)
@@ -73,7 +73,7 @@ func (f *fileWrapper) TotalSize() []byte {
return size
}
-func (f *fileWrapper) rsrcForkSize() (s [4]byte) {
+func (f *File) rsrcForkSize() (s [4]byte) {
info, err := f.fs.Stat(f.rsrcPath)
if err != nil {
return s
@@ -83,26 +83,26 @@ func (f *fileWrapper) rsrcForkSize() (s [4]byte) {
return s
}
-func (f *fileWrapper) rsrcForkHeader() FlatFileForkHeader {
+func (f *File) rsrcForkHeader() FlatFileForkHeader {
return FlatFileForkHeader{
- ForkType: [4]byte{0x4D, 0x41, 0x43, 0x52}, // "MACR"
+ ForkType: ForkTypeMACR,
DataSize: f.rsrcForkSize(),
}
}
-func (f *fileWrapper) incompleteDataName() string {
+func (f *File) incompleteDataName() string {
return f.Name + IncompleteFileSuffix
}
-func (f *fileWrapper) rsrcForkName() string {
+func (f *File) rsrcForkName() string {
return fmt.Sprintf(RsrcForkNameTemplate, f.Name)
}
-func (f *fileWrapper) infoForkName() string {
+func (f *File) infoForkName() string {
return fmt.Sprintf(InfoForkNameTemplate, f.Name)
}
-func (f *fileWrapper) rsrcForkWriter() (io.WriteCloser, error) {
+func (f *File) rsrcForkWriter() (io.WriteCloser, error) {
file, err := os.OpenFile(f.rsrcPath, os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return nil, err
@@ -111,7 +111,7 @@ func (f *fileWrapper) rsrcForkWriter() (io.WriteCloser, error) {
return file, nil
}
-func (f *fileWrapper) InfoForkWriter() (io.WriteCloser, error) {
+func (f *File) InfoForkWriter() (io.WriteCloser, error) {
file, err := os.OpenFile(f.infoPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return nil, err
@@ -120,7 +120,7 @@ func (f *fileWrapper) InfoForkWriter() (io.WriteCloser, error) {
return file, nil
}
-func (f *fileWrapper) incFileWriter() (io.WriteCloser, error) {
+func (f *File) incFileWriter() (io.WriteCloser, error) {
file, err := os.OpenFile(f.incompletePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return nil, err
@@ -129,15 +129,19 @@ func (f *fileWrapper) incFileWriter() (io.WriteCloser, error) {
return file, nil
}
-func (f *fileWrapper) dataForkReader() (io.Reader, error) {
- return f.fs.Open(f.dataPath)
+func (f *File) dataForkReader() (io.Reader, error) {
+ if _, err := f.fs.Stat(f.dataPath); err == nil {
+ return f.fs.Open(f.dataPath)
+ }
+
+ return f.fs.Open(f.incompletePath)
}
-func (f *fileWrapper) rsrcForkFile() (*os.File, error) {
+func (f *File) rsrcForkFile() (*os.File, error) {
return f.fs.Open(f.rsrcPath)
}
-func (f *fileWrapper) DataFile() (os.FileInfo, error) {
+func (f *File) DataFile() (os.FileInfo, error) {
if fi, err := f.fs.Stat(f.dataPath); err == nil {
return fi, nil
}
@@ -154,7 +158,7 @@ func (f *fileWrapper) DataFile() (os.FileInfo, error) {
// * Resource fork starting with .rsrc_
// * Info fork starting with .info
// During Move of the meta files, os.ErrNotExist is ignored as these files may legitimately not exist.
-func (f *fileWrapper) Move(newPath string) error {
+func (f *File) Move(newPath string) error {
err := f.fs.Rename(f.dataPath, filepath.Join(newPath, f.Name))
if err != nil {
return err
@@ -179,7 +183,7 @@ func (f *fileWrapper) Move(newPath string) error {
}
// Delete a file and its associated metadata files if they exist
-func (f *fileWrapper) Delete() error {
+func (f *File) Delete() error {
err := f.fs.RemoveAll(f.dataPath)
if err != nil {
return err
@@ -203,7 +207,7 @@ func (f *fileWrapper) Delete() error {
return nil
}
-func (f *fileWrapper) flattenedFileObject() (*flattenedFileObject, error) {
+func (f *File) flattenedFileObject() (*flattenedFileObject, error) {
dataSize := make([]byte, 4)
mTime := [8]byte{}
@@ -227,7 +231,7 @@ func (f *fileWrapper) flattenedFileObject() (*flattenedFileObject, error) {
}
f.Ffo.FlatFileHeader = FlatFileHeader{
- Format: [4]byte{0x46, 0x49, 0x4c, 0x50}, // "FILP"
+ Format: FormatFILP,
Version: [2]byte{0, 1},
ForkCount: [2]byte{0, 2},
}
@@ -247,7 +251,7 @@ func (f *fileWrapper) flattenedFileObject() (*flattenedFileObject, error) {
}
} else {
f.Ffo.FlatFileInformationFork = FlatFileInformationFork{
- Platform: [4]byte{0x41, 0x4D, 0x41, 0x43}, // "AMAC" TODO: Remove hardcode to support "AWIN" Platform (maybe?)
+ Platform: PlatformAMAC, // TODO: Remove hardcode to support "MWIN" Platform (maybe?)
TypeSignature: [4]byte([]byte(ft.TypeCode)),
CreatorSignature: [4]byte([]byte(ft.CreatorCode)),
PlatformFlags: [4]byte{0, 0, 1, 0}, // TODO: What is this?
@@ -263,12 +267,12 @@ func (f *fileWrapper) flattenedFileObject() (*flattenedFileObject, error) {
}
f.Ffo.FlatFileInformationForkHeader = FlatFileForkHeader{
- ForkType: [4]byte{0x49, 0x4E, 0x46, 0x4F}, // "INFO"
+ ForkType: ForkTypeINFO,
DataSize: f.Ffo.FlatFileInformationFork.Size(),
}
f.Ffo.FlatFileDataForkHeader = FlatFileForkHeader{
- ForkType: [4]byte{0x44, 0x41, 0x54, 0x41}, // "DATA"
+ ForkType: ForkTypeDATA,
DataSize: [4]byte{dataSize[0], dataSize[1], dataSize[2], dataSize[3]},
}
f.Ffo.FlatFileResForkHeader = f.rsrcForkHeader()
diff --git a/hotline/files.go b/hotline/files.go
index a1db329..581b11c 100644
--- a/hotline/files.go
+++ b/hotline/files.go
@@ -12,7 +12,7 @@ import (
"strings"
)
-func fileTypeFromFilename(filename string) fileType {
+func FileTypeFromFilename(filename string) fileType {
fileExt := strings.ToLower(filepath.Ext(filename))
ft, ok := fileTypes[fileExt]
if ok {
@@ -26,7 +26,7 @@ func fileTypeFromInfo(info fs.FileInfo) (ft fileType, err error) {
ft.CreatorCode = "n/a "
ft.TypeCode = "fldr"
} else {
- ft = fileTypeFromFilename(info.Name())
+ ft = FileTypeFromFilename(info.Name())
}
return ft, nil
@@ -87,8 +87,8 @@ func GetFileNameList(path string, ignoreList []string) (fields []Field, err erro
copy(fnwi.Creator[:], fileCreator)
} else {
binary.BigEndian.PutUint32(fnwi.FileSize[:], uint32(rFile.Size()))
- copy(fnwi.Type[:], fileTypeFromFilename(rFile.Name()).TypeCode)
- copy(fnwi.Creator[:], fileTypeFromFilename(rFile.Name()).CreatorCode)
+ copy(fnwi.Type[:], FileTypeFromFilename(rFile.Name()).TypeCode)
+ copy(fnwi.Creator[:], FileTypeFromFilename(rFile.Name()).CreatorCode)
}
} else if file.IsDir() {
dir, err := os.ReadDir(filepath.Join(path, file.Name()))
@@ -112,9 +112,9 @@ func GetFileNameList(path string, ignoreList []string) (fields []Field, err erro
continue
}
- hlFile, err := NewFileWrapper(&OSFileStore{}, path+"/"+file.Name(), 0)
+ hlFile, err := NewFile(&OSFileStore{}, path+"/"+file.Name(), 0)
if err != nil {
- return nil, fmt.Errorf("NewFileWrapper: %w", err)
+ return nil, fmt.Errorf("NewFile: %w", err)
}
copy(fnwi.FileSize[:], hlFile.TotalSize())
diff --git a/hotline/files_test.go b/hotline/files_test.go
index 0a7eb7b..9bed670 100644
--- a/hotline/files_test.go
+++ b/hotline/files_test.go
@@ -148,7 +148,7 @@ func TestCalcItemCount(t *testing.T) {
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
- defer os.RemoveAll(tempDir)
+ defer func() { _ = os.RemoveAll(tempDir) }()
// Create the test directory structure
if err := createTestDirStructure(tempDir, tt.structure); err != nil {
diff --git a/hotline/flattened_file_object.go b/hotline/flattened_file_object.go
index 99ce204..7c0cd7d 100644
--- a/hotline/flattened_file_object.go
+++ b/hotline/flattened_file_object.go
@@ -45,7 +45,7 @@ type FlatFileInformationFork struct {
func NewFlatFileInformationFork(fileName string, modifyTime [8]byte, typeSignature string, creatorSignature string) FlatFileInformationFork {
return FlatFileInformationFork{
- Platform: [4]byte{0x41, 0x4D, 0x41, 0x43}, // "AMAC" TODO: Remove hardcode to support "AWIN" Platform (maybe?)
+ Platform: PlatformAMAC, // TODO: Remove hardcode to support "MWIN" Platform (maybe?)
TypeSignature: [4]byte([]byte(typeSignature)), // TODO: Don't infer types from filename
CreatorSignature: [4]byte([]byte(creatorSignature)), // TODO: Don't infer types from filename
PlatformFlags: [4]byte{0, 0, 1, 0}, // TODO: What is this?
@@ -128,7 +128,7 @@ func (ffif *FlatFileInformationFork) ReadNameSize() []byte {
}
type FlatFileForkHeader struct {
- ForkType [4]byte // Either INFO, DATA or MACR
+ ForkType ForkType // Either INFO, DATA or MACR
CompressionType [4]byte
RSVD [4]byte
DataSize [4]byte
diff --git a/hotline/news.go b/hotline/news.go
index a490a89..1ddeebd 100644
--- a/hotline/news.go
+++ b/hotline/news.go
@@ -14,7 +14,7 @@ var (
)
type ThreadedNewsMgr interface {
- ListArticles(newsPath []string) NewsArtListData
+ ListArticles(newsPath []string) (NewsArtListData, error)
GetArticle(newsPath []string, articleID uint32) *NewsArtData
DeleteArticle(newsPath []string, articleID uint32, recursive bool) error
PostArticle(newsPath []string, parentArticleID uint32, article NewsArtData) error
@@ -41,13 +41,13 @@ type NewsCategoryListData15 struct {
readOffset int // Internal offset to track read progress
}
-func (newscat *NewsCategoryListData15) GetNewsArtListData() NewsArtListData {
+func (newscat *NewsCategoryListData15) GetNewsArtListData() (NewsArtListData, error) {
var newsArts []NewsArtList
var newsArtsPayload []byte
for i, art := range newscat.Articles {
id := make([]byte, 4)
- binary.BigEndian.PutUint32(id, i)
+ binary.BigEndian.PutUint32(id, i) // The article's map key in the Articles map is its ID.
newsArts = append(newsArts, NewsArtList{
ID: [4]byte(id),
@@ -70,8 +70,7 @@ func (newscat *NewsCategoryListData15) GetNewsArtListData() NewsArtListData {
for _, v := range newsArts {
b, err := io.ReadAll(&v)
if err != nil {
- // TODO
- panic(err)
+ return NewsArtListData{}, err
}
newsArtsPayload = append(newsArtsPayload, b...)
}
@@ -81,7 +80,7 @@ func (newscat *NewsCategoryListData15) GetNewsArtListData() NewsArtListData {
Name: []byte{},
Description: []byte{},
NewsArtList: newsArtsPayload,
- }
+ }, nil
}
// NewsArtData represents an individual news article.
@@ -157,8 +156,8 @@ type NewsArtList struct {
}
var (
- NewsFlavorLen = []byte{0x0a}
- NewsFlavor = []byte("text/plain")
+ NewsFlavor = []byte("text/plain") // NewsFlavor is always "text/plain"
+ NewsFlavorCount = []byte{0, 1} // NewsFlavorCount is always 1
)
func (nal *NewsArtList) Read(p []byte) (int, error) {
@@ -167,12 +166,12 @@ func (nal *NewsArtList) Read(p []byte) (int, error) {
nal.TimeStamp[:],
nal.ParentID[:],
nal.Flags[:],
- []byte{0, 1}, // Flavor Count TODO: make this not hardcoded
+ NewsFlavorCount,
[]byte{uint8(len(nal.Title))},
nal.Title,
[]byte{uint8(len(nal.Poster))},
nal.Poster,
- NewsFlavorLen,
+ []byte{uint8(len(NewsFlavor))},
NewsFlavor,
nal.ArticleSize[:],
)
@@ -184,7 +183,7 @@ func (nal *NewsArtList) Read(p []byte) (int, error) {
n := copy(p, out[nal.readOffset:])
nal.readOffset += n
- return n, io.EOF
+ return n, nil
}
type NewsFlavorList struct {
@@ -242,10 +241,10 @@ type MockThreadNewsMgr struct {
mock.Mock
}
-func (m *MockThreadNewsMgr) ListArticles(newsPath []string) NewsArtListData {
+func (m *MockThreadNewsMgr) ListArticles(newsPath []string) (NewsArtListData, error) {
args := m.Called(newsPath)
- return args.Get(0).(NewsArtListData)
+ return args.Get(0).(NewsArtListData), args.Error(1)
}
func (m *MockThreadNewsMgr) GetArticle(newsPath []string, articleID uint32) *NewsArtData {
diff --git a/hotline/news_test.go b/hotline/news_test.go
index d1b043e..46df2e3 100644
--- a/hotline/news_test.go
+++ b/hotline/news_test.go
@@ -97,3 +97,274 @@ func TestNewsCategoryListData15_MarshalBinary(t *testing.T) {
})
}
}
+
+func TestNewsCategoryListData15_GetNewsArtListData(t *testing.T) {
+ tests := []struct {
+ name string
+ newscat NewsCategoryListData15
+ wantData NewsArtListData
+ wantErr bool
+ }{
+ {
+ name: "empty articles",
+ newscat: NewsCategoryListData15{
+ Articles: map[uint32]*NewsArtData{},
+ },
+ wantData: NewsArtListData{
+ Count: 0,
+ Name: []byte{},
+ Description: []byte{},
+ NewsArtList: []byte{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "single article",
+ newscat: NewsCategoryListData15{
+ Articles: map[uint32]*NewsArtData{
+ 1: {
+ Title: "Test Title",
+ Poster: "Test Poster",
+ Date: [8]byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07},
+ Data: "Test content",
+ },
+ },
+ },
+ wantData: NewsArtListData{
+ Count: 1,
+ Name: []byte{},
+ Description: []byte{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "multiple articles",
+ newscat: NewsCategoryListData15{
+ Articles: map[uint32]*NewsArtData{
+ 2: {
+ Title: "Second Article",
+ Poster: "Author2",
+ Date: [8]byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x08},
+ Data: "Second content",
+ },
+ 1: {
+ Title: "First Article",
+ Poster: "Author1",
+ Date: [8]byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07},
+ Data: "First content",
+ },
+ },
+ },
+ wantData: NewsArtListData{
+ Count: 2,
+ Name: []byte{},
+ Description: []byte{},
+ },
+ wantErr: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotData, err := tt.newscat.GetNewsArtListData()
+ if (err != nil) != tt.wantErr {
+ t.Errorf("GetNewsArtListData() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ assert.Equal(t, tt.wantData.Count, gotData.Count)
+ assert.Equal(t, tt.wantData.Name, gotData.Name)
+ assert.Equal(t, tt.wantData.Description, gotData.Description)
+ if tt.wantData.Count > 0 {
+ assert.NotEmpty(t, gotData.NewsArtList)
+ }
+ })
+ }
+}
+
+func TestNewsArtData_DataSize(t *testing.T) {
+ tests := []struct {
+ name string
+ art NewsArtData
+ want [2]byte
+ }{
+ {
+ name: "empty data",
+ art: NewsArtData{Data: ""},
+ want: [2]byte{0x00, 0x00},
+ },
+ {
+ name: "short data",
+ art: NewsArtData{Data: "hello"},
+ want: [2]byte{0x00, 0x05},
+ },
+ {
+ name: "longer data",
+ art: NewsArtData{Data: "This is a longer test message with more content"},
+ want: [2]byte{0x00, 0x2F}, // 47 bytes
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := tt.art.DataSize()
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestNewsArtListData_Read(t *testing.T) {
+ tests := []struct {
+ name string
+ nald NewsArtListData
+ bufferSize int
+ wantN int
+ wantErr bool
+ }{
+ {
+ name: "empty data",
+ nald: NewsArtListData{
+ ID: [4]byte{0x00, 0x01, 0x02, 0x03},
+ Name: []byte("test"),
+ Description: []byte("desc"),
+ NewsArtList: []byte{},
+ Count: 0,
+ },
+ bufferSize: 100,
+ wantN: 18, // 4 (ID) + 4 (count) + 1 (name len) + 4 (name) + 1 (desc len) + 4 (desc) + 0 (news art list)
+ wantErr: false,
+ },
+ {
+ name: "with article list",
+ nald: NewsArtListData{
+ ID: [4]byte{0x00, 0x01, 0x02, 0x03},
+ Name: []byte("test"),
+ Description: []byte("desc"),
+ NewsArtList: []byte{0x01, 0x02, 0x03},
+ Count: 1,
+ },
+ bufferSize: 100,
+ wantN: 21, // 4 (ID) + 4 (count) + 1 (name len) + 4 (name) + 1 (desc len) + 4 (desc) + 3 (news art list)
+ wantErr: false,
+ },
+ {
+ name: "small buffer",
+ nald: NewsArtListData{
+ ID: [4]byte{0x00, 0x01, 0x02, 0x03},
+ Name: []byte("test"),
+ Description: []byte("desc"),
+ Count: 0,
+ },
+ bufferSize: 5,
+ wantN: 5,
+ wantErr: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ p := make([]byte, tt.bufferSize)
+ gotN, err := tt.nald.Read(p)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("Read() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ assert.Equal(t, tt.wantN, gotN)
+ })
+ }
+}
+
+func TestNewsArtList_Read(t *testing.T) {
+ tests := []struct {
+ name string
+ nal NewsArtList
+ bufferSize int
+ wantN int
+ wantErr bool
+ }{
+ {
+ name: "basic article",
+ nal: NewsArtList{
+ ID: [4]byte{0x00, 0x01, 0x02, 0x03},
+ TimeStamp: [8]byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07},
+ ParentID: [4]byte{0x00, 0x00, 0x00, 0x00},
+ Flags: [4]byte{0x00, 0x00, 0x00, 0x00},
+ Title: []byte("Test Title"),
+ Poster: []byte("Test Poster"),
+ ArticleSize: [2]byte{0x00, 0x0A},
+ },
+ bufferSize: 100,
+ wantN: 58, // 4 (ID) + 8 (timestamp) + 4 (parent) + 4 (flags) + 2 (flavor count) + 1 (title len) + 10 (title) + 1 (poster len) + 11 (poster) + 1 (flavor len) + 10 (flavor) + 2 (article size)
+ wantErr: false,
+ },
+ {
+ name: "small buffer",
+ nal: NewsArtList{
+ ID: [4]byte{0x00, 0x01, 0x02, 0x03},
+ TimeStamp: [8]byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07},
+ Title: []byte("Test"),
+ Poster: []byte("Author"),
+ },
+ bufferSize: 10,
+ wantN: 10,
+ wantErr: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ p := make([]byte, tt.bufferSize)
+ gotN, err := tt.nal.Read(p)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("Read() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ assert.Equal(t, tt.wantN, gotN)
+ })
+ }
+}
+
+func TestNewsPathScanner(t *testing.T) {
+ tests := []struct {
+ name string
+ data []byte
+ wantAdvance int
+ wantToken []byte
+ wantErr bool
+ }{
+ {
+ name: "insufficient data",
+ data: []byte{0x00, 0x01},
+ wantAdvance: 0,
+ wantToken: nil,
+ wantErr: false,
+ },
+ {
+ name: "valid token",
+ data: []byte{0x00, 0x01, 0x04, 0x74, 0x65, 0x73, 0x74}, // length 4, "test"
+ wantAdvance: 7,
+ wantToken: []byte("test"),
+ wantErr: false,
+ },
+ {
+ name: "zero length token",
+ data: []byte{0x00, 0x01, 0x00},
+ wantAdvance: 3,
+ wantToken: []byte{},
+ wantErr: false,
+ },
+ {
+ name: "single character token",
+ data: []byte{0x00, 0x01, 0x01, 0x61}, // length 1, "a"
+ wantAdvance: 4,
+ wantToken: []byte("a"),
+ wantErr: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotAdvance, gotToken, err := newsPathScanner(tt.data, false)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("newsPathScanner() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ assert.Equal(t, tt.wantAdvance, gotAdvance)
+ assert.Equal(t, tt.wantToken, gotToken)
+ })
+ }
+}
diff --git a/hotline/server.go b/hotline/server.go
index a521a6b..9b74d99 100644
--- a/hotline/server.go
+++ b/hotline/server.go
@@ -5,11 +5,10 @@ import (
"bytes"
"context"
"crypto/rand"
+ "crypto/tls"
"encoding/binary"
"errors"
"fmt"
- "golang.org/x/text/encoding/charmap"
- "golang.org/x/time/rate"
"io"
"log"
"log/slog"
@@ -18,6 +17,10 @@ import (
"strings"
"sync"
"time"
+
+ "github.com/redis/go-redis/v9"
+ "golang.org/x/text/encoding/charmap"
+ "golang.org/x/time/rate"
)
type contextKey string
@@ -38,7 +41,8 @@ type Server struct {
NetInterface string
Port int
- rateLimiters map[string]*rate.Limiter
+ rateLimiters map[string]*rate.Limiter
+ rateLimitersMu sync.Mutex
handlers map[TranType]HandlerFunc
@@ -64,6 +68,14 @@ type Server struct {
BanList BanMgr
MessageBoard io.ReadWriteSeeker
+
+ Redis *redis.Client
+
+ // TrackerRegistrar handles tracker registration (injectable for testing)
+ TrackerRegistrar TrackerRegistrar
+
+ TLSConfig *tls.Config
+ TLSPort int
}
type Option = func(s *Server)
@@ -94,19 +106,35 @@ func WithInterface(netInterface string) func(s *Server) {
}
}
+// WithTrackerRegistrar optionally sets a custom tracker registrar (useful for testing).
+func WithTrackerRegistrar(registrar TrackerRegistrar) func(s *Server) {
+ return func(s *Server) {
+ s.TrackerRegistrar = registrar
+ }
+}
+
+// WithTLS optionally enables TLS support on the specified port.
+func WithTLS(tlsConfig *tls.Config, port int) func(s *Server) {
+ return func(s *Server) {
+ s.TLSConfig = tlsConfig
+ s.TLSPort = port
+ }
+}
+
type ServerConfig struct {
}
func NewServer(options ...Option) (*Server, error) {
server := Server{
- handlers: make(map[TranType]HandlerFunc),
- outbox: make(chan Transaction),
- rateLimiters: make(map[string]*rate.Limiter),
- FS: &OSFileStore{},
- ChatMgr: NewMemChatManager(),
- ClientMgr: NewMemClientMgr(),
- FileTransferMgr: NewMemFileTransferMgr(),
- Stats: NewStats(),
+ handlers: make(map[TranType]HandlerFunc),
+ outbox: make(chan Transaction),
+ rateLimiters: make(map[string]*rate.Limiter),
+ FS: &OSFileStore{},
+ ChatMgr: NewMemChatManager(),
+ ClientMgr: NewMemClientMgr(),
+ FileTransferMgr: NewMemFileTransferMgr(),
+ Stats: NewStats(),
+ TrackerRegistrar: NewRealTrackerRegistrar(),
}
for _, opt := range options {
@@ -153,6 +181,28 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
log.Fatal(s.ServeFileTransfers(ctx, ln))
}()
+ if s.TLSConfig != nil {
+ wg.Add(1)
+ go func() {
+ ln, err := net.Listen("tcp", fmt.Sprintf("%s:%v", s.NetInterface, s.TLSPort))
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ log.Fatal(s.ServeWithTLS(ctx, ln))
+ }()
+
+ wg.Add(1)
+ go func() {
+ ln, err := net.Listen("tcp", fmt.Sprintf("%s:%v", s.NetInterface, s.TLSPort+1))
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ log.Fatal(s.ServeFileTransfersWithTLS(ctx, ln))
+ }()
+ }
+
wg.Wait()
return nil
@@ -180,6 +230,14 @@ func (s *Server) ServeFileTransfers(ctx context.Context, ln net.Listener) error
}
}
+func (s *Server) ServeWithTLS(ctx context.Context, ln net.Listener) error {
+ return s.Serve(ctx, tls.NewListener(ln, s.TLSConfig))
+}
+
+func (s *Server) ServeFileTransfersWithTLS(ctx context.Context, ln net.Listener) error {
+ return s.ServeFileTransfers(ctx, tls.NewListener(ln, s.TLSConfig))
+}
+
func (s *Server) sendTransaction(t Transaction) error {
client := s.ClientMgr.Get(t.ClientID)
@@ -224,26 +282,28 @@ func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
}
go func() {
- ipAddr := strings.Split(conn.RemoteAddr().(*net.TCPAddr).String(), ":")[0]
+ ipAddr, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
connCtx := context.WithValue(ctx, contextKeyReq, requestCtx{
remoteAddr: conn.RemoteAddr().String(),
})
s.Logger.Info("Connection established", "ip", ipAddr)
- defer conn.Close()
+ defer func() { _ = conn.Close() }()
// Check if we have an existing rate limit for the IP and create one if we do not.
+ s.rateLimitersMu.Lock()
rl, ok := s.rateLimiters[ipAddr]
if !ok {
rl = rate.NewLimiter(perIPRateLimit, 1)
s.rateLimiters[ipAddr] = rl
}
+ s.rateLimitersMu.Unlock()
// Check if the rate limit is exceeded and close the connection if so.
if !rl.Allow() {
s.Logger.Info("Rate limit exceeded", "RemoteAddr", conn.RemoteAddr())
- conn.Close()
+ _ = conn.Close()
return
}
@@ -262,6 +322,62 @@ func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
// time in seconds between tracker re-registration
const trackerUpdateFrequency = 300
+// TrackerRegistrar interface for tracker registration operations
+type TrackerRegistrar interface {
+ Register(tracker string, registration *TrackerRegistration) error
+}
+
+// RealTrackerRegistrar implements TrackerRegistrar using the real network operations
+type RealTrackerRegistrar struct {
+ dialer Dialer
+}
+
+func NewRealTrackerRegistrar() *RealTrackerRegistrar {
+ return &RealTrackerRegistrar{
+ dialer: &RealDialer{},
+ }
+}
+
+func (r *RealTrackerRegistrar) Register(tracker string, registration *TrackerRegistration) error {
+ return register(r.dialer, tracker, registration)
+}
+
+// parseTrackerPassword extracts the password from a tracker address in format "host:port:password"
+// Returns empty string if no password is present or if the format is invalid
+// For addresses with more than 3 parts (like passwords containing colons), everything after the second colon is treated as the password
+func parseTrackerPassword(trackerAddr string) string {
+ splitAddr := strings.Split(trackerAddr, ":")
+ if len(splitAddr) >= 3 {
+ // Join everything from the third part onwards (index 2+) to handle passwords with colons
+ return strings.Join(splitAddr[2:], ":")
+ }
+ return ""
+}
+
+// registerWithAllTrackers performs tracker registration for all configured trackers
+func (s *Server) registerWithAllTrackers() {
+ if !s.Config.EnableTrackerRegistration {
+ return
+ }
+
+ for _, t := range s.Config.Trackers {
+ tr := &TrackerRegistration{
+ UserCount: len(s.ClientMgr.List()),
+ PassID: s.TrackerPassID,
+ Name: s.Config.Name,
+ Description: s.Config.Description,
+ }
+ binary.BigEndian.PutUint16(tr.Port[:], uint16(s.Port))
+ binary.BigEndian.PutUint16(tr.TLSPort[:], uint16(s.TLSPort))
+
+ tr.Password = parseTrackerPassword(t)
+
+ if err := s.TrackerRegistrar.Register(t, tr); err != nil {
+ s.Logger.Error(fmt.Sprintf("Unable to register with tracker %v", t), "error", err)
+ }
+ }
+}
+
// registerWithTrackers runs every trackerUpdateFrequency seconds to update the server's tracker entry on all configured
// trackers.
func (s *Server) registerWithTrackers(ctx context.Context) {
@@ -269,33 +385,19 @@ func (s *Server) registerWithTrackers(ctx context.Context) {
s.Logger.Info("Tracker registration enabled", "trackers", s.Config.Trackers)
}
- for {
- if s.Config.EnableTrackerRegistration {
- for _, t := range s.Config.Trackers {
- tr := &TrackerRegistration{
- UserCount: len(s.ClientMgr.List()),
- PassID: s.TrackerPassID,
- Name: s.Config.Name,
- Description: s.Config.Description,
- }
- binary.BigEndian.PutUint16(tr.Port[:], uint16(s.Port))
+ // Do the first registration immediately
+ s.registerWithAllTrackers()
- // Check the tracker string for a password. This is janky but avoids a breaking change to the Config
- // Trackers field.
- splitAddr := strings.Split(":", t)
- if len(splitAddr) == 3 {
- tr.Password = splitAddr[2]
- }
+ ticker := time.NewTicker(trackerUpdateFrequency * time.Second)
+ defer ticker.Stop()
- if err := register(&RealDialer{}, t, tr); err != nil {
- s.Logger.Error(fmt.Sprintf("Unable to register with tracker %v", t), "error", err)
- }
- }
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ s.registerWithAllTrackers()
}
- // Using time.Ticker with for/select would be more idiomatic, but it's super annoying that it doesn't tick on
- // first pass. Revist, maybe.
- // https://github.com/golang/go/issues/17601
- time.Sleep(trackerUpdateFrequency * time.Second)
}
}
@@ -372,24 +474,6 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser
return fmt.Errorf("perform handshake: %w", err)
}
- // Check if remoteAddr is present in the ban list
- ipAddr := strings.Split(remoteAddr, ":")[0]
- if isBanned, banUntil := s.BanList.IsBanned(ipAddr); isBanned {
- // permaban
- if banUntil == nil {
- sendBanMessage(rwc, "You are permanently banned on this server")
- s.Logger.Debug("Disconnecting permanently banned IP", "remoteAddr", ipAddr)
- return nil
- }
-
- // temporary ban
- if time.Now().Before(*banUntil) {
- sendBanMessage(rwc, "You are temporarily banned on this server")
- s.Logger.Debug("Disconnecting temporarily banned IP", "remoteAddr", ipAddr)
- return nil
- }
- }
-
// Create a new scanner for parsing incoming bytes into transaction tokens
scanner := bufio.NewScanner(rwc)
scanner.Split(transactionScanner)
@@ -406,17 +490,66 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser
return fmt.Errorf("error writing login transaction: %w", err)
}
- c := s.NewClientConn(rwc, remoteAddr)
- defer c.Disconnect()
-
- encodedPassword := clientLogin.GetField(FieldUserPassword).Data
- c.Version = clientLogin.GetField(FieldVersion).Data
-
login := clientLogin.GetField(FieldUserLogin).DecodeObfuscatedString()
if login == "" {
login = GuestAccount
}
+ // Check if remoteAddr is present in the ban list, we do this after we have the login name
+ ipAddr, _, _ := net.SplitHostPort(remoteAddr)
+ if s.Redis != nil {
+ // Redis-based ban check
+ bannedUser, _ := s.Redis.SIsMember(ctx, "mobius:banned:users", login).Result()
+ bannedIP, _ := s.Redis.SIsMember(ctx, "mobius:banned:ips", ipAddr).Result()
+ if bannedUser {
+ s.Redis.SAdd(ctx, "mobius:banned:ips", ipAddr)
+ sendBanMessage(rwc, "You are banned on this server")
+ s.Logger.Debug("Disconnecting banned user", "login", login, "ip", ipAddr)
+ return nil
+ }
+ if bannedIP {
+ sendBanMessage(rwc, "You are banned on this server")
+ s.Logger.Debug("Disconnecting banned IP", "ip", ipAddr)
+ return nil
+ }
+ } else {
+ // Fallback to in-memory ban list
+ if isBanned, banUntil := s.BanList.IsBanned(ipAddr); isBanned {
+ // permaban
+ if banUntil == nil {
+ sendBanMessage(rwc, "You are permanently banned on this server")
+ s.Logger.Debug("Disconnecting permanently banned IP", "remoteAddr", ipAddr)
+ return nil
+ }
+ // temporary ban
+ if time.Now().Before(*banUntil) {
+ sendBanMessage(rwc, "You are temporarily banned on this server")
+ s.Logger.Debug("Disconnecting temporarily banned IP", "remoteAddr", ipAddr)
+ return nil
+ }
+ }
+ }
+
+ c := s.NewClientConn(rwc, remoteAddr)
+ // Add the client to the list of connected clients
+ if s.Redis != nil {
+ s.Redis.SAdd(context.Background(), "mobius:online", login+"::"+ipAddr)
+ }
+
+ // Remove the client from the list of connected clients when they disconnect
+ defer func() {
+ if s.Redis != nil {
+ s.Redis.SRem(context.Background(), "mobius:online", login+"::"+ipAddr)
+ if len(c.UserName) != 0 {
+ s.Redis.SRem(context.Background(), "mobius:online", login+":"+string(c.UserName)+":"+ipAddr)
+ }
+ }
+ c.Disconnect()
+ }()
+
+ encodedPassword := clientLogin.GetField(FieldUserPassword).Data
+ c.Version = clientLogin.GetField(FieldVersion).Data
+
c.Logger = s.Logger.With("ip", ipAddr, "login", login)
// If authentication fails, send error reply and close connection
@@ -486,6 +619,14 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser
c.Logger = c.Logger.With("name", string(c.UserName))
c.Logger.Info("Login successful")
+ // Update the Redis set with the new information
+ if s.Redis != nil && len(c.UserName) != 0 {
+ // Remove old entry (login::ip)
+ s.Redis.SRem(context.Background(), "mobius:online", login+"::"+ipAddr)
+ // Add new entry with login, nickname, ip
+ s.Redis.SAdd(context.Background(), "mobius:online", login+":"+string(c.UserName)+":"+ipAddr)
+ }
+
// Notify other clients on the server that the new user has logged in. For 1.5+ clients we don't have this
// information yet, so we do it in TranAgreed instead
for _, t := range c.NotifyOthers(
@@ -605,12 +746,12 @@ func (s *Server) handleFileTransfer(ctx context.Context, rwc io.ReadWriter) erro
var transferSizeValue uint32
switch len(fileTransfer.TransferSize) {
- case 2: // 16-bit
- transferSizeValue = uint32(binary.BigEndian.Uint16(fileTransfer.TransferSize))
- case 4: // 32-bit
- transferSizeValue = binary.BigEndian.Uint32(fileTransfer.TransferSize)
- default:
- rLogger.Warn("Unexpected TransferSize length: %d bytes", len(fileTransfer.TransferSize))
+ case 2: // 16-bit
+ transferSizeValue = uint32(binary.BigEndian.Uint16(fileTransfer.TransferSize))
+ case 4: // 32-bit
+ transferSizeValue = binary.BigEndian.Uint32(fileTransfer.TransferSize)
+ default:
+ rLogger.Warn("Unexpected TransferSize length", "bytes", len(fileTransfer.TransferSize))
}
rLogger.Info(
diff --git a/hotline/server_test.go b/hotline/server_test.go
index d0e2372..574aae7 100644
--- a/hotline/server_test.go
+++ b/hotline/server_test.go
@@ -3,12 +3,15 @@ package hotline
import (
"bytes"
"context"
+ "encoding/binary"
"fmt"
"github.com/stretchr/testify/assert"
"io"
"log/slog"
"os"
+ "strings"
"testing"
+ "time"
)
type mockReadWriter struct {
@@ -181,3 +184,583 @@ func TestServer_handleFileTransfer(t *testing.T) {
})
}
}
+
+func TestParseTrackerPassword(t *testing.T) {
+ tests := []struct {
+ name string
+ trackerAddr string
+ wantPassword string
+ }{
+ {
+ name: "tracker address with password",
+ trackerAddr: "tracker.example.com:5500:mypassword",
+ wantPassword: "mypassword",
+ },
+ {
+ name: "tracker address without password",
+ trackerAddr: "tracker.example.com:5500",
+ wantPassword: "",
+ },
+ {
+ name: "tracker address with empty password",
+ trackerAddr: "tracker.example.com:5500:",
+ wantPassword: "",
+ },
+ {
+ name: "tracker address with password containing special characters",
+ trackerAddr: "tracker.example.com:5500:pass@word#123",
+ wantPassword: "pass@word#123",
+ },
+ {
+ name: "tracker address with password containing colons",
+ trackerAddr: "tracker.example.com:5500:pass:word:123",
+ wantPassword: "pass:word:123",
+ },
+ {
+ name: "IPv4 address with password",
+ trackerAddr: "192.168.1.100:5500:secret",
+ wantPassword: "secret",
+ },
+ {
+ name: "IPv4 address without password",
+ trackerAddr: "192.168.1.100:5500",
+ wantPassword: "",
+ },
+ {
+ name: "malformed address - no port",
+ trackerAddr: "tracker.example.com",
+ wantPassword: "",
+ },
+ {
+ name: "malformed address - empty string",
+ trackerAddr: "",
+ wantPassword: "",
+ },
+ {
+ name: "malformed address - only colons",
+ trackerAddr: ":::",
+ wantPassword: ":",
+ },
+ {
+ name: "IPv6 address handling (edge case - not properly supported)",
+ trackerAddr: "[::1]:5500:password",
+ wantPassword: "1]:5500:password", // IPv6 addresses aren't properly handled by simple colon splitting
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := parseTrackerPassword(tt.trackerAddr)
+ assert.Equal(t, tt.wantPassword, got)
+ })
+ }
+}
+
+// MockTrackerRegistrar is a mock implementation of TrackerRegistrar for testing
+type MockTrackerRegistrar struct {
+ RegisterCalls []RegisterCall
+ RegisterFunc func(tracker string, registration *TrackerRegistration) error
+}
+
+type RegisterCall struct {
+ Tracker string
+ Registration *TrackerRegistration
+}
+
+func (m *MockTrackerRegistrar) Register(tracker string, registration *TrackerRegistration) error {
+ // Record the call
+ m.RegisterCalls = append(m.RegisterCalls, RegisterCall{
+ Tracker: tracker,
+ Registration: registration,
+ })
+
+ // Use custom function if provided, otherwise return nil (success)
+ if m.RegisterFunc != nil {
+ return m.RegisterFunc(tracker, registration)
+ }
+ return nil
+}
+
+func (m *MockTrackerRegistrar) Reset() {
+ m.RegisterCalls = nil
+ m.RegisterFunc = nil
+}
+
+func TestServer_registerWithTrackers(t *testing.T) {
+ tests := []struct {
+ name string
+ config Config
+ wantImmediateRegistration bool
+ wantTrackerCalls []string
+ mockRegisterFunc func(tracker string, registration *TrackerRegistration) error
+ expectError bool
+ }{
+ {
+ name: "disabled tracker registration",
+ config: Config{
+ EnableTrackerRegistration: false,
+ Trackers: []string{"tracker1.example.com:5500", "tracker2.example.com:5500:password"},
+ },
+ wantImmediateRegistration: false,
+ wantTrackerCalls: []string{},
+ },
+ {
+ name: "enabled tracker registration with multiple trackers",
+ config: Config{
+ EnableTrackerRegistration: true,
+ Trackers: []string{"tracker1.example.com:5500", "tracker2.example.com:5500:password"},
+ Name: "Test Server",
+ Description: "Test Description",
+ },
+ wantImmediateRegistration: true,
+ wantTrackerCalls: []string{"tracker1.example.com:5500", "tracker2.example.com:5500:password"},
+ },
+ {
+ name: "enabled tracker registration with empty tracker list",
+ config: Config{
+ EnableTrackerRegistration: true,
+ Trackers: []string{},
+ Name: "Test Server",
+ Description: "Test Description",
+ },
+ wantImmediateRegistration: true,
+ wantTrackerCalls: []string{},
+ },
+ {
+ name: "tracker registration with network errors",
+ config: Config{
+ EnableTrackerRegistration: true,
+ Trackers: []string{"tracker1.example.com:5500"},
+ Name: "Test Server",
+ Description: "Test Description",
+ },
+ wantImmediateRegistration: true,
+ wantTrackerCalls: []string{"tracker1.example.com:5500"},
+ mockRegisterFunc: func(tracker string, registration *TrackerRegistration) error {
+ return assert.AnError // Simulate network error
+ },
+ expectError: false, // Errors are logged but don't stop the function
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Create mock registrar
+ mockRegistrar := &MockTrackerRegistrar{
+ RegisterFunc: tt.mockRegisterFunc,
+ }
+
+ // Create server with mock registrar
+ server, err := NewServer(
+ WithConfig(tt.config),
+ WithLogger(NewTestLogger()),
+ WithTrackerRegistrar(mockRegistrar),
+ )
+ assert.NoError(t, err)
+
+ // Create a context that we can cancel
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ // Start the registerWithTrackers function in a goroutine
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ server.registerWithTrackers(ctx)
+ }()
+
+ // Give it a moment to do the immediate registration
+ time.Sleep(100 * time.Millisecond)
+
+ // Cancel the context to stop the goroutine
+ cancel()
+
+ // Wait for the goroutine to finish (should be quick after cancellation)
+ select {
+ case <-done:
+ // Success
+ case <-time.After(1 * time.Second):
+ t.Fatal("registerWithTrackers did not exit after context cancellation")
+ }
+
+ // Verify the calls made to the mock registrar
+ assert.Len(t, mockRegistrar.RegisterCalls, len(tt.wantTrackerCalls))
+
+ for i, expectedTracker := range tt.wantTrackerCalls {
+ if i < len(mockRegistrar.RegisterCalls) {
+ call := mockRegistrar.RegisterCalls[i]
+ assert.Equal(t, expectedTracker, call.Tracker)
+ assert.Equal(t, tt.config.Name, call.Registration.Name)
+ assert.Equal(t, tt.config.Description, call.Registration.Description)
+ assert.Equal(t, parseTrackerPassword(expectedTracker), call.Registration.Password)
+ }
+ }
+ })
+ }
+}
+
+func TestServer_registerWithTrackers_ContextCancellation(t *testing.T) {
+ tests := []struct {
+ name string
+ cancelAfter time.Duration
+ expectedCalls int // Number of expected registration calls before cancellation
+ trackerCount int
+ }{
+ {
+ name: "immediate cancellation",
+ cancelAfter: 10 * time.Millisecond,
+ expectedCalls: 2, // Should complete immediate registration
+ trackerCount: 2,
+ },
+ {
+ name: "cancellation after first ticker",
+ cancelAfter: 100 * time.Millisecond,
+ expectedCalls: 2, // Should only do immediate registration within 100ms
+ trackerCount: 2,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockRegistrar := &MockTrackerRegistrar{}
+ config := Config{
+ EnableTrackerRegistration: true,
+ Trackers: make([]string, tt.trackerCount),
+ Name: "Test Server",
+ Description: "Test Description",
+ }
+
+ // Fill trackers array
+ for i := 0; i < tt.trackerCount; i++ {
+ config.Trackers[i] = fmt.Sprintf("tracker%d.example.com:5500", i+1)
+ }
+
+ server, err := NewServer(
+ WithConfig(config),
+ WithLogger(NewTestLogger()),
+ WithTrackerRegistrar(mockRegistrar),
+ )
+ assert.NoError(t, err)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ server.registerWithTrackers(ctx)
+ }()
+
+ // Wait for the specified time then cancel
+ time.Sleep(tt.cancelAfter)
+ cancel()
+
+ // Wait for graceful shutdown
+ select {
+ case <-done:
+ // Success
+ case <-time.After(1 * time.Second):
+ t.Fatal("registerWithTrackers did not exit after context cancellation")
+ }
+
+ // Verify that the function respects context cancellation
+ assert.Equal(t, tt.expectedCalls, len(mockRegistrar.RegisterCalls))
+ })
+ }
+}
+
+func TestServer_registerWithTrackers_PeriodicRegistration(t *testing.T) {
+ t.Skip("Skipping timing-sensitive test - would take 5+ minutes to run reliably")
+
+ // This test would verify that periodic re-registration happens every trackerUpdateFrequency seconds
+ // but it's impractical to run in normal test suites due to the 300-second interval
+
+ mockRegistrar := &MockTrackerRegistrar{}
+ config := Config{
+ EnableTrackerRegistration: true,
+ Trackers: []string{"tracker1.example.com:5500"},
+ Name: "Test Server",
+ Description: "Test Description",
+ }
+
+ server, err := NewServer(
+ WithConfig(config),
+ WithLogger(NewTestLogger()),
+ WithTrackerRegistrar(mockRegistrar),
+ )
+ assert.NoError(t, err)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ server.registerWithTrackers(ctx)
+ }()
+
+ // Wait for timeout or completion
+ <-ctx.Done()
+
+ // Should have done immediate registration only (1 call) in 10 seconds
+ // since trackerUpdateFrequency is 300 seconds
+ assert.Equal(t, 1, len(mockRegistrar.RegisterCalls))
+}
+
+func TestServer_registerWithTrackers_ErrorHandling(t *testing.T) {
+ tests := []struct {
+ name string
+ trackers []string
+ mockRegisterFunc func(tracker string, registration *TrackerRegistration) error
+ expectPanic bool
+ }{
+ {
+ name: "handles network errors gracefully",
+ trackers: []string{"tracker1.example.com:5500", "tracker2.example.com:5500:password"},
+ mockRegisterFunc: func(tracker string, registration *TrackerRegistration) error {
+ if tracker == "tracker1.example.com:5500" {
+ return fmt.Errorf("network error: connection refused")
+ }
+ return nil // Second tracker succeeds
+ },
+ expectPanic: false,
+ },
+ {
+ name: "handles all trackers failing",
+ trackers: []string{"tracker1.example.com:5500", "tracker2.example.com:5500"},
+ mockRegisterFunc: func(tracker string, registration *TrackerRegistration) error {
+ return fmt.Errorf("network error")
+ },
+ expectPanic: false,
+ },
+ {
+ name: "handles empty tracker addresses",
+ trackers: []string{"", "valid.tracker.com:5500", ""},
+ mockRegisterFunc: func(tracker string, registration *TrackerRegistration) error {
+ if tracker == "" {
+ return fmt.Errorf("invalid tracker address")
+ }
+ return nil
+ },
+ expectPanic: false,
+ },
+ {
+ name: "handles malformed tracker addresses",
+ trackers: []string{"invalid-address", "another:invalid", "valid.tracker.com:5500:password"},
+ mockRegisterFunc: func(tracker string, registration *TrackerRegistration) error {
+ return nil // Accept all for this test
+ },
+ expectPanic: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockRegistrar := &MockTrackerRegistrar{
+ RegisterFunc: tt.mockRegisterFunc,
+ }
+
+ config := Config{
+ EnableTrackerRegistration: true,
+ Trackers: tt.trackers,
+ Name: "Test Server",
+ Description: "Test Description",
+ }
+
+ server, err := NewServer(
+ WithConfig(config),
+ WithLogger(NewTestLogger()),
+ WithTrackerRegistrar(mockRegistrar),
+ )
+ assert.NoError(t, err)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ if tt.expectPanic {
+ assert.Panics(t, func() {
+ server.registerWithTrackers(ctx)
+ })
+ return
+ }
+
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ server.registerWithTrackers(ctx)
+ }()
+
+ // Give it time to process
+ time.Sleep(50 * time.Millisecond)
+ cancel()
+
+ select {
+ case <-done:
+ // Success - function completed without panicking
+ case <-time.After(1 * time.Second):
+ t.Fatal("registerWithTrackers did not exit after context cancellation")
+ }
+
+ // Verify all trackers were attempted
+ assert.Equal(t, len(tt.trackers), len(mockRegistrar.RegisterCalls))
+ })
+ }
+}
+
+func TestServer_registerWithTrackers_EdgeCases(t *testing.T) {
+ tests := []struct {
+ name string
+ config Config
+ expectedCalls int
+ validateResult func(t *testing.T, calls []RegisterCall)
+ }{
+ {
+ name: "server with zero port",
+ config: Config{
+ EnableTrackerRegistration: true,
+ Trackers: []string{"tracker.example.com:5500"},
+ Name: "Test Server",
+ Description: "Test Description",
+ },
+ expectedCalls: 1,
+ validateResult: func(t *testing.T, calls []RegisterCall) {
+ assert.Equal(t, uint16(0), binary.BigEndian.Uint16(calls[0].Registration.Port[:]))
+ },
+ },
+ {
+ name: "server with very long name and description",
+ config: Config{
+ EnableTrackerRegistration: true,
+ Trackers: []string{"tracker.example.com:5500"},
+ Name: strings.Repeat("A", 255), // Max uint8 length
+ Description: strings.Repeat("B", 255),
+ },
+ expectedCalls: 1,
+ validateResult: func(t *testing.T, calls []RegisterCall) {
+ assert.Equal(t, strings.Repeat("A", 255), calls[0].Registration.Name)
+ assert.Equal(t, strings.Repeat("B", 255), calls[0].Registration.Description)
+ },
+ },
+ {
+ name: "empty server name and description",
+ config: Config{
+ EnableTrackerRegistration: true,
+ Trackers: []string{"tracker.example.com:5500"},
+ Name: "",
+ Description: "",
+ },
+ expectedCalls: 1,
+ validateResult: func(t *testing.T, calls []RegisterCall) {
+ assert.Equal(t, "", calls[0].Registration.Name)
+ assert.Equal(t, "", calls[0].Registration.Description)
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockRegistrar := &MockTrackerRegistrar{}
+
+ server, err := NewServer(
+ WithConfig(tt.config),
+ WithLogger(NewTestLogger()),
+ WithTrackerRegistrar(mockRegistrar),
+ )
+ assert.NoError(t, err)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ server.registerWithTrackers(ctx)
+ }()
+
+ time.Sleep(50 * time.Millisecond)
+ cancel()
+
+ select {
+ case <-done:
+ // Success
+ case <-time.After(1 * time.Second):
+ t.Fatal("registerWithTrackers did not exit after context cancellation")
+ }
+
+ assert.Equal(t, tt.expectedCalls, len(mockRegistrar.RegisterCalls))
+ if tt.validateResult != nil {
+ tt.validateResult(t, mockRegistrar.RegisterCalls)
+ }
+ })
+ }
+}
+
+func TestServer_registerWithAllTrackers(t *testing.T) {
+ tests := []struct {
+ name string
+ config Config
+ expectRegistrationAttempt bool
+ expectedTrackerCalls []string
+ }{
+ {
+ name: "disabled tracker registration",
+ config: Config{
+ EnableTrackerRegistration: false,
+ Trackers: []string{"tracker1.example.com:5500"},
+ },
+ expectRegistrationAttempt: false,
+ expectedTrackerCalls: []string{},
+ },
+ {
+ name: "enabled tracker registration with multiple trackers",
+ config: Config{
+ EnableTrackerRegistration: true,
+ Trackers: []string{"tracker1.example.com:5500", "tracker2.example.com:5500:password"},
+ Name: "Test Server",
+ Description: "Test Description",
+ },
+ expectRegistrationAttempt: true,
+ expectedTrackerCalls: []string{"tracker1.example.com:5500", "tracker2.example.com:5500:password"},
+ },
+ {
+ name: "enabled tracker registration with empty tracker list",
+ config: Config{
+ EnableTrackerRegistration: true,
+ Trackers: []string{},
+ Name: "Test Server",
+ Description: "Test Description",
+ },
+ expectRegistrationAttempt: true,
+ expectedTrackerCalls: []string{},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockRegistrar := &MockTrackerRegistrar{}
+
+ server, err := NewServer(
+ WithConfig(tt.config),
+ WithLogger(NewTestLogger()),
+ WithTrackerRegistrar(mockRegistrar),
+ )
+ assert.NoError(t, err)
+
+ // Call the extracted function directly
+ server.registerWithAllTrackers()
+
+ // Verify the expected number of calls
+ assert.Equal(t, len(tt.expectedTrackerCalls), len(mockRegistrar.RegisterCalls))
+
+ // Verify each call
+ for i, expectedTracker := range tt.expectedTrackerCalls {
+ if i < len(mockRegistrar.RegisterCalls) {
+ call := mockRegistrar.RegisterCalls[i]
+ assert.Equal(t, expectedTracker, call.Tracker)
+ assert.Equal(t, tt.config.Name, call.Registration.Name)
+ assert.Equal(t, tt.config.Description, call.Registration.Description)
+ assert.Equal(t, parseTrackerPassword(expectedTracker), call.Registration.Password)
+ }
+ }
+ })
+ }
+}
diff --git a/hotline/stats.go b/hotline/stats.go
index 316a67d..9731601 100644
--- a/hotline/stats.go
+++ b/hotline/stats.go
@@ -61,7 +61,9 @@ func (s *Stats) Decrement(key int) {
s.mu.Lock()
defer s.mu.Unlock()
- s.stats[key]--
+ if s.stats[key] > 0 {
+ s.stats[key]--
+ }
}
func (s *Stats) Set(key, val int) {
diff --git a/hotline/stats_test.go b/hotline/stats_test.go
new file mode 100644
index 0000000..2174227
--- /dev/null
+++ b/hotline/stats_test.go
@@ -0,0 +1,293 @@
+package hotline
+
+import (
+ "github.com/stretchr/testify/assert"
+ "testing"
+ "time"
+)
+
+func TestStats_Increment(t *testing.T) {
+ tests := []struct {
+ name string
+ keys []int
+ expected map[int]int
+ }{
+ {
+ name: "single key increment",
+ keys: []int{StatCurrentlyConnected},
+ expected: map[int]int{
+ StatCurrentlyConnected: 1,
+ },
+ },
+ {
+ name: "multiple keys increment",
+ keys: []int{StatCurrentlyConnected, StatDownloadCounter, StatUploadCounter},
+ expected: map[int]int{
+ StatCurrentlyConnected: 1,
+ StatDownloadCounter: 1,
+ StatUploadCounter: 1,
+ },
+ },
+ {
+ name: "duplicate keys increment",
+ keys: []int{StatCurrentlyConnected, StatCurrentlyConnected},
+ expected: map[int]int{
+ StatCurrentlyConnected: 2,
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ stats := NewStats()
+ stats.Increment(tt.keys...)
+
+ for key, expectedVal := range tt.expected {
+ assert.Equal(t, expectedVal, stats.Get(key))
+ }
+ })
+ }
+}
+
+func TestStats_Increment_Multiple_Calls(t *testing.T) {
+ stats := NewStats()
+
+ stats.Increment(StatCurrentlyConnected)
+ assert.Equal(t, 1, stats.Get(StatCurrentlyConnected))
+
+ stats.Increment(StatCurrentlyConnected)
+ assert.Equal(t, 2, stats.Get(StatCurrentlyConnected))
+
+ stats.Increment(StatCurrentlyConnected, StatDownloadCounter)
+ assert.Equal(t, 3, stats.Get(StatCurrentlyConnected))
+ assert.Equal(t, 1, stats.Get(StatDownloadCounter))
+}
+
+func TestStats_Decrement(t *testing.T) {
+ tests := []struct {
+ name string
+ setupValue int
+ key int
+ expected int
+ }{
+ {
+ name: "decrement from positive value",
+ setupValue: 5,
+ key: StatCurrentlyConnected,
+ expected: 4,
+ },
+ {
+ name: "decrement from zero stays zero",
+ setupValue: 0,
+ key: StatCurrentlyConnected,
+ expected: 0,
+ },
+ {
+ name: "decrement from one",
+ setupValue: 1,
+ key: StatCurrentlyConnected,
+ expected: 0,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ stats := NewStats()
+ stats.Set(tt.key, tt.setupValue)
+
+ stats.Decrement(tt.key)
+
+ assert.Equal(t, tt.expected, stats.Get(tt.key))
+ })
+ }
+}
+
+func TestStats_Decrement_Multiple_Calls(t *testing.T) {
+ stats := NewStats()
+
+ stats.Set(StatCurrentlyConnected, 10)
+ assert.Equal(t, 10, stats.Get(StatCurrentlyConnected))
+
+ stats.Decrement(StatCurrentlyConnected)
+ assert.Equal(t, 9, stats.Get(StatCurrentlyConnected))
+
+ stats.Decrement(StatCurrentlyConnected)
+ assert.Equal(t, 8, stats.Get(StatCurrentlyConnected))
+}
+
+func TestStats_Set(t *testing.T) {
+ tests := []struct {
+ name string
+ key int
+ value int
+ expected int
+ }{
+ {
+ name: "set positive value",
+ key: StatCurrentlyConnected,
+ value: 42,
+ expected: 42,
+ },
+ {
+ name: "set zero value",
+ key: StatDownloadCounter,
+ value: 0,
+ expected: 0,
+ },
+ {
+ name: "set negative value",
+ key: StatUploadCounter,
+ value: -5,
+ expected: -5,
+ },
+ {
+ name: "overwrite existing value",
+ key: StatConnectionPeak,
+ value: 100,
+ expected: 100,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ stats := NewStats()
+
+ if tt.name == "overwrite existing value" {
+ stats.Set(tt.key, 50)
+ }
+
+ stats.Set(tt.key, tt.value)
+
+ assert.Equal(t, tt.expected, stats.Get(tt.key))
+ })
+ }
+}
+
+func TestStats_Get(t *testing.T) {
+ tests := []struct {
+ name string
+ key int
+ setValue int
+ expected int
+ }{
+ {
+ name: "get initialized value",
+ key: StatCurrentlyConnected,
+ setValue: 0,
+ expected: 0,
+ },
+ {
+ name: "get set value",
+ key: StatDownloadCounter,
+ setValue: 25,
+ expected: 25,
+ },
+ {
+ name: "get after increment",
+ key: StatUploadCounter,
+ setValue: -1,
+ expected: 1,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ stats := NewStats()
+
+ if tt.name == "get after increment" {
+ stats.Increment(tt.key)
+ } else {
+ stats.Set(tt.key, tt.setValue)
+ }
+
+ result := stats.Get(tt.key)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestStats_Get_Default_Values(t *testing.T) {
+ stats := NewStats()
+
+ expectedDefaults := map[int]int{
+ StatCurrentlyConnected: 0,
+ StatDownloadsInProgress: 0,
+ StatUploadsInProgress: 0,
+ StatWaitingDownloads: 0,
+ StatConnectionPeak: 0,
+ StatDownloadCounter: 0,
+ StatUploadCounter: 0,
+ StatConnectionCounter: 0,
+ }
+
+ for key, expected := range expectedDefaults {
+ assert.Equal(t, expected, stats.Get(key))
+ }
+}
+
+func TestStats_Values(t *testing.T) {
+ stats := NewStats()
+
+ // Test default values
+ values := stats.Values()
+
+ assert.Equal(t, 0, values["CurrentlyConnected"])
+ assert.Equal(t, 0, values["DownloadsInProgress"])
+ assert.Equal(t, 0, values["UploadsInProgress"])
+ assert.Equal(t, 0, values["WaitingDownloads"])
+ assert.Equal(t, 0, values["ConnectionPeak"])
+ assert.Equal(t, 0, values["ConnectionCounter"])
+ assert.Equal(t, 0, values["DownloadCounter"])
+ assert.Equal(t, 0, values["UploadCounter"])
+ assert.NotNil(t, values["Since"])
+
+ // Verify Since is a time.Time
+ _, ok := values["Since"].(time.Time)
+ assert.True(t, ok, "Since should be a time.Time")
+}
+
+func TestStats_Values_WithModifiedStats(t *testing.T) {
+ stats := NewStats()
+
+ // Modify some stats
+ stats.Set(StatCurrentlyConnected, 10)
+ stats.Set(StatDownloadsInProgress, 5)
+ stats.Increment(StatConnectionCounter)
+ stats.Increment(StatDownloadCounter, StatUploadCounter)
+
+ values := stats.Values()
+
+ assert.Equal(t, 10, values["CurrentlyConnected"])
+ assert.Equal(t, 5, values["DownloadsInProgress"])
+ assert.Equal(t, 0, values["UploadsInProgress"])
+ assert.Equal(t, 0, values["WaitingDownloads"])
+ assert.Equal(t, 0, values["ConnectionPeak"])
+ assert.Equal(t, 1, values["ConnectionCounter"])
+ assert.Equal(t, 1, values["DownloadCounter"])
+ assert.Equal(t, 1, values["UploadCounter"])
+}
+
+func TestStats_Values_ContainsAllKeys(t *testing.T) {
+ stats := NewStats()
+ values := stats.Values()
+
+ expectedKeys := []string{
+ "CurrentlyConnected",
+ "DownloadsInProgress",
+ "UploadsInProgress",
+ "WaitingDownloads",
+ "ConnectionPeak",
+ "ConnectionCounter",
+ "DownloadCounter",
+ "UploadCounter",
+ "Since",
+ }
+
+ for _, key := range expectedKeys {
+ _, exists := values[key]
+ assert.True(t, exists, "Key %s should exist in Values() output", key)
+ }
+
+ // Should have exactly 9 keys
+ assert.Equal(t, 9, len(values))
+} \ No newline at end of file
diff --git a/hotline/tracker.go b/hotline/tracker.go
index 52963bf..ee4fc05 100644
--- a/hotline/tracker.go
+++ b/hotline/tracker.go
@@ -15,6 +15,7 @@ import (
type TrackerRegistration struct {
Port [2]byte // Server's listening TCP port number
UserCount int // Number of users connected to this particular server
+ TLSPort [2]byte // TLSPort
PassID [4]byte // Random number generated by the server
Name string // Server Name
Description string // Description of the server
@@ -32,7 +33,7 @@ func (tr *TrackerRegistration) Read(p []byte) (int, error) {
[]byte{0x00, 0x01}, // Magic number, always 1
tr.Port[:],
userCount,
- []byte{0x00, 0x00}, // Magic number, always 0
+ tr.TLSPort[:],
tr.PassID[:],
[]byte{uint8(len(tr.Name))},
[]byte(tr.Name),
@@ -69,7 +70,7 @@ func register(dialer Dialer, tracker string, tr io.Reader) error {
if err != nil {
return fmt.Errorf("failed to dial tracker: %v", err)
}
- defer conn.Close()
+ defer func() { _ = conn.Close() }()
if _, err := io.Copy(conn, tr); err != nil {
return fmt.Errorf("failed to write to connection: %w", err)
@@ -91,11 +92,22 @@ type TrackerHeader struct {
Version [2]byte // Old protocol (1) or new (2)
}
+// ServerInfoHeader represents a batch header in the tracker response.
+// The tracker protocol splits large server lists into batches, with each batch
+// preceded by its own ServerInfoHeader. The first header indicates the total
+// number of servers across all batches, and each header (including the first)
+// indicates how many servers are in the current batch.
+//
+// Example flow for 106 servers split into batches:
+// 1. First ServerInfoHeader: SrvCount=106, BatchSize=97
+// 2. Read 97 ServerRecords
+// 3. Second ServerInfoHeader: SrvCount=106, BatchSize=9
+// 4. Read 9 ServerRecords (total: 106)
type ServerInfoHeader struct {
MsgType [2]byte // Always has value of 1
MsgDataSize [2]byte // Remaining size of request
- SrvCount [2]byte // Number of servers in the server list
- SrvCountDup [2]byte // Same as previous field ¯\_(ツ)_/¯
+ SrvCount [2]byte // Total number of servers across all batches
+ BatchSize [2]byte // Number of servers in the current batch
}
// ServerRecord is a tracker listing for a single server
@@ -103,7 +115,7 @@ type ServerRecord struct {
IPAddr [4]byte
Port [2]byte
NumUsers [2]byte // Number of users connected to this particular server
- Unused [2]byte
+ TLSPort [2]byte
NameSize byte // Length of Name string
Name []byte // Server Name
DescriptionSize byte
@@ -128,79 +140,92 @@ func GetListing(conn io.ReadWriteCloser) ([]ServerRecord, error) {
return nil, err
}
+ // Use a buffered reader so we can read both headers and server records from the same buffer
+ reader := bufio.NewReader(conn)
+
var info ServerInfoHeader
- if err := binary.Read(conn, binary.BigEndian, &info); err != nil {
+ if err := binary.Read(reader, binary.BigEndian, &info); err != nil {
return nil, err
}
totalSrv := int(binary.BigEndian.Uint16(info.SrvCount[:]))
+ batchSize := int(binary.BigEndian.Uint16(info.BatchSize[:]))
- scanner := bufio.NewScanner(conn)
- scanner.Split(serverScanner)
+ servers := make([]ServerRecord, 0, totalSrv)
+ serversInCurrentBatch := 0
- var servers []ServerRecord
- for {
- scanner.Scan()
+ for len(servers) < totalSrv {
+ // Check if we've read all servers in the current batch
+ if serversInCurrentBatch == batchSize {
+ // Read the next ServerInfoHeader for the next batch from the buffered reader
+ if err := binary.Read(reader, binary.BigEndian, &info); err != nil {
+ return nil, fmt.Errorf("failed to read next ServerInfoHeader after %d servers: %w", len(servers), err)
+ }
- // Make a new []byte slice and copy the scanner bytes to it. This is critical as the
- // scanner re-uses the buffer for subsequent scans.
- buf := make([]byte, len(scanner.Bytes()))
- copy(buf, scanner.Bytes())
+ batchSize = int(binary.BigEndian.Uint16(info.BatchSize[:]))
+ serversInCurrentBatch = 0
+ }
- var srv ServerRecord
- _, err = srv.Write(buf)
+ // Read a server record using our helper function
+ srv, err := readServerRecord(reader)
if err != nil {
- return nil, err
+ return nil, fmt.Errorf("failed to read server record %d: %w", len(servers)+1, err)
}
servers = append(servers, srv)
- if len(servers) == totalSrv {
- break
- }
+ serversInCurrentBatch++
}
return servers, nil
}
-// serverScanner implements bufio.SplitFunc for parsing the tracker list into ServerRecords tokens
-// Example payload:
-// 00000000 18 05 30 63 15 7c 00 02 00 00 10 54 68 65 20 4d |..0c.|.....The M|
-// 00000010 6f 62 69 75 73 20 53 74 72 69 70 40 48 6f 6d 65 |obius Strip@Home|
-// 00000020 20 6f 66 20 74 68 65 20 4d 6f 62 69 75 73 20 48 | of the Mobius H|
-// 00000030 6f 74 6c 69 6e 65 20 73 65 72 76 65 72 20 61 6e |otline server an|
-// 00000040 64 20 63 6c 69 65 6e 74 20 7c 20 54 52 54 50 48 |d client | TRTPH|
-// 00000050 4f 54 4c 2e 63 6f 6d 3a 35 35 30 30 2d 4f 3a b2 |OTL.com:5500-O:.|
-// 00000060 15 7c 00 00 00 00 08 53 65 6e 65 63 74 75 73 20 |.|.....Senectus |
-func serverScanner(data []byte, _ bool) (advance int, token []byte, err error) {
- // The name length field is the 11th byte of the server record. If we don't have that many bytes,
- // return nil token so the Scanner reads more data and continues scanning.
- if len(data) < 10 {
- return 0, nil, nil
+// readServerRecord reads a single ServerRecord from the reader
+func readServerRecord(reader *bufio.Reader) (ServerRecord, error) {
+ var srv ServerRecord
+
+ // Read fixed header: IP (4) + Port (2) + NumUsers (2) + TLSPort (2) = 10 bytes
+ header := make([]byte, 10)
+ if _, err := io.ReadFull(reader, header); err != nil {
+ return srv, fmt.Errorf("failed to read server header: %w", err)
}
- // A server entry has two variable length fields: the name and description.
- // To get the token length, we first need the name length from the 10th byte
- nameLen := int(data[10])
+ copy(srv.IPAddr[:], header[0:4])
+ copy(srv.Port[:], header[4:6])
+ copy(srv.NumUsers[:], header[6:8])
+ copy(srv.TLSPort[:], header[8:10])
- // The description length field is at the 12th + nameLen byte of the server record.
- // If we don't have that many bytes, return nil token so the Scanner reads more data and continues scanning.
- if len(data) < 11+nameLen {
- return 0, nil, nil
+ // Read name size
+ nameSizeByte, err := reader.ReadByte()
+ if err != nil {
+ return srv, fmt.Errorf("failed to read name size: %w", err)
+ }
+ srv.NameSize = nameSizeByte
+
+ // Read name
+ srv.Name = make([]byte, srv.NameSize)
+ if _, err := io.ReadFull(reader, srv.Name); err != nil {
+ return srv, fmt.Errorf("failed to read name: %w", err)
}
- // Next we need the description length from the 11+nameLen byte:
- descLen := int(data[11+nameLen])
+ // Read description size
+ descSizeByte, err := reader.ReadByte()
+ if err != nil {
+ return srv, fmt.Errorf("failed to read description size: %w", err)
+ }
+ srv.DescriptionSize = descSizeByte
- if len(data) < 12+nameLen+descLen {
- return 0, nil, nil
+ // Read description
+ srv.Description = make([]byte, srv.DescriptionSize)
+ if _, err := io.ReadFull(reader, srv.Description); err != nil {
+ return srv, fmt.Errorf("failed to read description: %w", err)
}
- return 12 + nameLen + descLen, data[0 : 12+nameLen+descLen], nil
+ return srv, nil
}
// Write implements io.Writer for ServerRecord
func (s *ServerRecord) Write(b []byte) (n int, err error) {
- if len(b) < 13 {
+ if len(b) < 12 {
return 0, errors.New("too few bytes")
}
copy(s.IPAddr[:], b[0:4])
diff --git a/hotline/tracker_test.go b/hotline/tracker_test.go
index 5457e47..242deb1 100644
--- a/hotline/tracker_test.go
+++ b/hotline/tracker_test.go
@@ -2,11 +2,11 @@ package hotline
import (
"bytes"
- "fmt"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
"io"
"testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestTrackerRegistration_Payload(t *testing.T) {
@@ -62,138 +62,6 @@ func TestTrackerRegistration_Payload(t *testing.T) {
}
}
-func Test_serverScanner(t *testing.T) {
- type args struct {
- data []byte
- atEOF bool
- }
- tests := []struct {
- name string
- args args
- wantAdvance int
- wantToken []byte
- wantErr assert.ErrorAssertionFunc
- }{
- {
- name: "when a full server entry is provided",
- args: args{
- data: []byte{
- 0x18, 0x05, 0x30, 0x63, // IP Addr
- 0x15, 0x7c, // Port
- 0x00, 0x02, // UserCount
- 0x00, 0x00, // ??
- 0x03, // Name Len
- 0x54, 0x68, 0x65, // Name
- 0x03, // Desc Len
- 0x54, 0x54, 0x54, // Description
- },
- atEOF: false,
- },
- wantAdvance: 18,
- wantToken: []byte{
- 0x18, 0x05, 0x30, 0x63, // IP Addr
- 0x15, 0x7c, // Port
- 0x00, 0x02, // UserCount
- 0x00, 0x00, // ??
- 0x03, // Name Len
- 0x54, 0x68, 0x65, // Name
- 0x03, // Desc Len
- 0x54, 0x54, 0x54, // Description
- },
- wantErr: assert.NoError,
- },
- {
- name: "when extra bytes are provided",
- args: args{
- data: []byte{
- 0x18, 0x05, 0x30, 0x63, // IP Addr
- 0x15, 0x7c, // Port
- 0x00, 0x02, // UserCount
- 0x00, 0x00, // ??
- 0x03, // Name Len
- 0x54, 0x68, 0x65, // Name
- 0x03, // Desc Len
- 0x54, 0x54, 0x54, // Description
- 0x54, 0x54, 0x54, 0x54, 0x54, 0x54, 0x54, 0x54, 0x54,
- },
- atEOF: false,
- },
- wantAdvance: 18,
- wantToken: []byte{
- 0x18, 0x05, 0x30, 0x63, // IP Addr
- 0x15, 0x7c, // Port
- 0x00, 0x02, // UserCount
- 0x00, 0x00, // ??
- 0x03, // Name Len
- 0x54, 0x68, 0x65, // Name
- 0x03, // Desc Len
- 0x54, 0x54, 0x54, // Description
- },
- wantErr: assert.NoError,
- },
- {
- name: "when insufficient bytes are provided",
- args: args{
- data: []byte{
- 0, 0,
- },
- atEOF: false,
- },
- wantAdvance: 0,
- wantToken: []byte(nil),
- wantErr: assert.NoError,
- },
- {
- name: "when nameLen exceeds provided data",
- args: args{
- data: []byte{
- 0x18, 0x05, 0x30, 0x63, // IP Addr
- 0x15, 0x7c, // Port
- 0x00, 0x02, // UserCount
- 0x00, 0x00, // ??
- 0xff, // Name Len
- 0x54, 0x68, 0x65, // Name
- 0x03, // Desc Len
- 0x54, 0x54, 0x54, // Description
- },
- atEOF: false,
- },
- wantAdvance: 0,
- wantToken: []byte(nil),
- wantErr: assert.NoError,
- },
- {
- name: "when description len exceeds provided data",
- args: args{
- data: []byte{
- 0x18, 0x05, 0x30, 0x63, // IP Addr
- 0x15, 0x7c, // Port
- 0x00, 0x02, // UserCount
- 0x00, 0x00, // ??
- 0x03, // Name Len
- 0x54, 0x68, 0x65, // Name
- 0xff, // Desc Len
- 0x54, 0x54, 0x54, // Description
- },
- atEOF: false,
- },
- wantAdvance: 0,
- wantToken: []byte(nil),
- wantErr: assert.NoError,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gotAdvance, gotToken, err := serverScanner(tt.args.data, tt.args.atEOF)
- if !tt.wantErr(t, err, fmt.Sprintf("serverScanner(%v, %v)", tt.args.data, tt.args.atEOF)) {
- return
- }
- assert.Equalf(t, tt.wantAdvance, gotAdvance, "serverScanner(%v, %v)", tt.args.data, tt.args.atEOF)
- assert.Equalf(t, tt.wantToken, gotToken, "serverScanner(%v, %v)", tt.args.data, tt.args.atEOF)
- })
- }
-}
-
type mockConn struct {
readBuffer *bytes.Buffer
writeBuffer *bytes.Buffer
@@ -231,12 +99,12 @@ func TestGetListing(t *testing.T) {
0x00, 0x01, // MsgType (1)
0x00, 0x14, // MsgDataSize (20)
0x00, 0x02, // SrvCount (2)
- 0x00, 0x02, // SrvCountDup (2)
+ 0x00, 0x02, // BatchSize (2)
// ServerRecord 1
192, 168, 1, 1, // IP address
0x1F, 0x90, // Port 8080
0x00, 0x10, // NumUsers 16
- 0x00, 0x00, // Unused
+ 0x00, 0x00, // TLSPort
0x04, // NameSize
'S', 'e', 'r', 'v', // Name
0x0B, // DescriptionSize
@@ -245,7 +113,7 @@ func TestGetListing(t *testing.T) {
10, 0, 0, 1, // IP address
0x1F, 0x91, // Port 8081
0x00, 0x05, // NumUsers 5
- 0x00, 0x00, // Unused
+ 0x00, 0x00, // TLSPort
0x04, // NameSize
'S', 'e', 'r', 'v', // Name
0x0B, // DescriptionSize
@@ -259,7 +127,7 @@ func TestGetListing(t *testing.T) {
IPAddr: [4]byte{192, 168, 1, 1},
Port: [2]byte{0x1F, 0x90},
NumUsers: [2]byte{0x00, 0x10},
- Unused: [2]byte{0x00, 0x00},
+ TLSPort: [2]byte{0x00, 0x00},
NameSize: 4,
Name: []byte("Serv"),
DescriptionSize: 11,
@@ -269,7 +137,7 @@ func TestGetListing(t *testing.T) {
IPAddr: [4]byte{10, 0, 0, 1},
Port: [2]byte{0x1F, 0x91},
NumUsers: [2]byte{0x00, 0x05},
- Unused: [2]byte{0x00, 0x00},
+ TLSPort: [2]byte{0x00, 0x00},
NameSize: 4,
Name: []byte("Serv"),
DescriptionSize: 11,
@@ -324,7 +192,7 @@ func TestGetListing(t *testing.T) {
0x00, 0x01, // MsgType (1)
0x00, 0x14, // MsgDataSize (20)
0x00, 0x01, // SrvCount (1)
- 0x00, 0x01, // SrvCountDup (1)
+ 0x00, 0x01, // BatchSize (1)
// incomplete ServerRecord to cause scanner error
192, 168, 1, 1,
}),
@@ -333,6 +201,239 @@ func TestGetListing(t *testing.T) {
wantErr: true,
wantResult: nil,
},
+ {
+ name: "Multiple batches with ServerInfoHeaders",
+ mockConn: &mockConn{
+ readBuffer: bytes.NewBuffer([]byte{
+ // TrackerHeader
+ 0x48, 0x54, 0x52, 0x4B, // Protocol "HTRK"
+ 0x00, 0x01, // Version 1
+ // First ServerInfoHeader
+ 0x00, 0x01, // MsgType (1)
+ 0x00, 0x14, // MsgDataSize (20)
+ 0x00, 0x03, // SrvCount (3 total)
+ 0x00, 0x02, // BatchSize (2 in first batch)
+ // ServerRecord 1
+ 192, 168, 1, 1, // IP address
+ 0x1F, 0x90, // Port 8080
+ 0x00, 0x0A, // NumUsers 10
+ 0x00, 0x00, // TLSPort
+ 0x07, // NameSize
+ 'S', 'e', 'r', 'v', 'e', 'r', '1', // Name
+ 0x0C, // DescriptionSize
+ 'F', 'i', 'r', 's', 't', ' ', 'b', 'a', 't', 'c', 'h', '1', // Description
+ // ServerRecord 2
+ 192, 168, 1, 2, // IP address
+ 0x1F, 0x91, // Port 8081
+ 0x00, 0x14, // NumUsers 20
+ 0x00, 0x00, // TLSPort
+ 0x07, // NameSize
+ 'S', 'e', 'r', 'v', 'e', 'r', '2', // Name
+ 0x0C, // DescriptionSize
+ 'F', 'i', 'r', 's', 't', ' ', 'b', 'a', 't', 'c', 'h', '2', // Description
+ // Second ServerInfoHeader (next batch)
+ 0x00, 0x01, // MsgType (1)
+ 0x00, 0x0A, // MsgDataSize (10)
+ 0x00, 0x03, // SrvCount (3 total - same)
+ 0x00, 0x01, // BatchSize (1 in second batch)
+ // ServerRecord 3
+ 192, 168, 1, 3, // IP address
+ 0x1F, 0x92, // Port 8082
+ 0x00, 0x1E, // NumUsers 30
+ 0x00, 0x00, // TLSPort
+ 0x07, // NameSize
+ 'S', 'e', 'r', 'v', 'e', 'r', '3', // Name
+ 0x0D, // DescriptionSize
+ 'S', 'e', 'c', 'o', 'n', 'd', ' ', 'b', 'a', 't', 'c', 'h', '1', // Description
+ }),
+ writeBuffer: &bytes.Buffer{},
+ },
+ wantErr: false,
+ wantResult: []ServerRecord{
+ {
+ IPAddr: [4]byte{192, 168, 1, 1},
+ Port: [2]byte{0x1F, 0x90},
+ NumUsers: [2]byte{0x00, 0x0A},
+ TLSPort: [2]byte{0x00, 0x00},
+ NameSize: 7,
+ Name: []byte("Server1"),
+ DescriptionSize: 12,
+ Description: []byte("First batch1"),
+ },
+ {
+ IPAddr: [4]byte{192, 168, 1, 2},
+ Port: [2]byte{0x1F, 0x91},
+ NumUsers: [2]byte{0x00, 0x14},
+ TLSPort: [2]byte{0x00, 0x00},
+ NameSize: 7,
+ Name: []byte("Server2"),
+ DescriptionSize: 12,
+ Description: []byte("First batch2"),
+ },
+ {
+ IPAddr: [4]byte{192, 168, 1, 3},
+ Port: [2]byte{0x1F, 0x92},
+ NumUsers: [2]byte{0x00, 0x1E},
+ TLSPort: [2]byte{0x00, 0x00},
+ NameSize: 7,
+ Name: []byte("Server3"),
+ DescriptionSize: 13,
+ Description: []byte("Second batch1"),
+ },
+ },
+ },
+ {
+ name: "Three batches",
+ mockConn: &mockConn{
+ readBuffer: bytes.NewBuffer([]byte{
+ // TrackerHeader
+ 0x48, 0x54, 0x52, 0x4B, // Protocol "HTRK"
+ 0x00, 0x01, // Version 1
+ // First ServerInfoHeader
+ 0x00, 0x01, // MsgType (1)
+ 0x00, 0x0A, // MsgDataSize
+ 0x00, 0x04, // SrvCount (4 total)
+ 0x00, 0x02, // BatchSize (2 in first batch)
+ // ServerRecord 1
+ 192, 168, 1, 1, // IP
+ 0x15, 0x7c, // Port 5500
+ 0x00, 0x01, // NumUsers 1
+ 0x00, 0x00, // TLSPort
+ 0x01, // NameSize
+ 'A', // Name
+ 0x01, // DescriptionSize
+ '1', // Description
+ // ServerRecord 2
+ 192, 168, 1, 2, // IP
+ 0x15, 0x7c, // Port 5500
+ 0x00, 0x02, // NumUsers 2
+ 0x00, 0x00, // TLSPort
+ 0x01, // NameSize
+ 'B', // Name
+ 0x01, // DescriptionSize
+ '2', // Description
+ // Second ServerInfoHeader
+ 0x00, 0x01, // MsgType (1)
+ 0x00, 0x0A, // MsgDataSize
+ 0x00, 0x04, // SrvCount (4 total)
+ 0x00, 0x01, // BatchSize (1 in second batch)
+ // ServerRecord 3
+ 192, 168, 1, 3, // IP
+ 0x15, 0x7c, // Port 5500
+ 0x00, 0x03, // NumUsers 3
+ 0x00, 0x00, // TLSPort
+ 0x01, // NameSize
+ 'C', // Name
+ 0x01, // DescriptionSize
+ '3', // Description
+ // Third ServerInfoHeader
+ 0x00, 0x01, // MsgType (1)
+ 0x00, 0x0A, // MsgDataSize
+ 0x00, 0x04, // SrvCount (4 total)
+ 0x00, 0x01, // BatchSize (1 in third batch)
+ // ServerRecord 4
+ 192, 168, 1, 4, // IP
+ 0x15, 0x7c, // Port 5500
+ 0x00, 0x04, // NumUsers 4
+ 0x00, 0x00, // TLSPort
+ 0x01, // NameSize
+ 'D', // Name
+ 0x01, // DescriptionSize
+ '4', // Description
+ }),
+ writeBuffer: &bytes.Buffer{},
+ },
+ wantErr: false,
+ wantResult: []ServerRecord{
+ {
+ IPAddr: [4]byte{192, 168, 1, 1},
+ Port: [2]byte{0x15, 0x7c},
+ NumUsers: [2]byte{0x00, 0x01},
+ TLSPort: [2]byte{0x00, 0x00},
+ NameSize: 1,
+ Name: []byte("A"),
+ DescriptionSize: 1,
+ Description: []byte("1"),
+ },
+ {
+ IPAddr: [4]byte{192, 168, 1, 2},
+ Port: [2]byte{0x15, 0x7c},
+ NumUsers: [2]byte{0x00, 0x02},
+ TLSPort: [2]byte{0x00, 0x00},
+ NameSize: 1,
+ Name: []byte("B"),
+ DescriptionSize: 1,
+ Description: []byte("2"),
+ },
+ {
+ IPAddr: [4]byte{192, 168, 1, 3},
+ Port: [2]byte{0x15, 0x7c},
+ NumUsers: [2]byte{0x00, 0x03},
+ TLSPort: [2]byte{0x00, 0x00},
+ NameSize: 1,
+ Name: []byte("C"),
+ DescriptionSize: 1,
+ Description: []byte("3"),
+ },
+ {
+ IPAddr: [4]byte{192, 168, 1, 4},
+ Port: [2]byte{0x15, 0x7c},
+ NumUsers: [2]byte{0x00, 0x04},
+ TLSPort: [2]byte{0x00, 0x00},
+ NameSize: 1,
+ Name: []byte("D"),
+ DescriptionSize: 1,
+ Description: []byte("4"),
+ },
+ },
+ },
+ {
+ name: "Error reading second ServerInfoHeader",
+ mockConn: &mockConn{
+ readBuffer: bytes.NewBuffer([]byte{
+ // TrackerHeader
+ 0x48, 0x54, 0x52, 0x4B, // Protocol "HTRK"
+ 0x00, 0x01, // Version 1
+ // First ServerInfoHeader
+ 0x00, 0x01, // MsgType (1)
+ 0x00, 0x0A, // MsgDataSize
+ 0x00, 0x02, // SrvCount (2 total)
+ 0x00, 0x01, // BatchSize (1 in first batch)
+ // ServerRecord 1
+ 192, 168, 1, 1, // IP
+ 0x15, 0x7c, // Port 5500
+ 0x00, 0x01, // NumUsers 1
+ 0x00, 0x00, // TLSPort
+ 0x01, // NameSize
+ 'A', // Name
+ 0x01, // DescriptionSize
+ '1', // Description
+ // Incomplete second ServerInfoHeader
+ 0x00, 0x01, // MsgType only
+ }),
+ writeBuffer: &bytes.Buffer{},
+ },
+ wantErr: true,
+ wantResult: nil,
+ },
+ {
+ name: "Empty server list",
+ mockConn: &mockConn{
+ readBuffer: bytes.NewBuffer([]byte{
+ // TrackerHeader
+ 0x48, 0x54, 0x52, 0x4B, // Protocol "HTRK"
+ 0x00, 0x01, // Version 1
+ // ServerInfoHeader with 0 servers
+ 0x00, 0x01, // MsgType (1)
+ 0x00, 0x00, // MsgDataSize (0)
+ 0x00, 0x00, // SrvCount (0)
+ 0x00, 0x00, // BatchSize (0)
+ }),
+ writeBuffer: &bytes.Buffer{},
+ },
+ wantErr: false,
+ wantResult: []ServerRecord{},
+ },
}
for _, tt := range tests {
diff --git a/hotline/transaction.go b/hotline/transaction.go
index fa1e96d..a03030d 100644
--- a/hotline/transaction.go
+++ b/hotline/transaction.go
@@ -275,7 +275,7 @@ func (t *Transaction) Size() []byte {
return bs
}
-func (t *Transaction) GetField(id [2]byte) *Field {
+func (t *Transaction) GetField(id FieldType) *Field {
for _, field := range t.Fields {
if id == field.Type {
return &field
diff --git a/hotline/transfer_test.go b/hotline/transfer_test.go
index 4aa142b..9155ad7 100644
--- a/hotline/transfer_test.go
+++ b/hotline/transfer_test.go
@@ -120,14 +120,14 @@ func Test_receiveFile(t *testing.T) {
conn: func() io.Reader {
testFile := flattenedFileObject{
FlatFileHeader: FlatFileHeader{
- Format: [4]byte{0x46, 0x49, 0x4c, 0x50}, // "FILP"
+ Format: FormatFILP,
Version: [2]byte{0, 1},
ForkCount: [2]byte{0, 2},
},
FlatFileInformationForkHeader: FlatFileForkHeader{},
FlatFileInformationFork: NewFlatFileInformationFork("testfile.txt", [8]byte{}, "TEXT", "TEXT"),
FlatFileDataForkHeader: FlatFileForkHeader{
- ForkType: [4]byte{0x4d, 0x41, 0x43, 0x52}, // DATA
+ ForkType: ForkTypeMACR,
DataSize: [4]byte{0x00, 0x00, 0x00, 0x03},
},
}
@@ -143,7 +143,7 @@ func Test_receiveFile(t *testing.T) {
wantErr: assert.NoError,
},
// {
- // Name: "transfers fileWrapper when there is a resource fork",
+ // Name: "transfers File when there is a resource fork",
// args: args{
// conn: func() io.Reader {
// testFile := flattenedFileObject{
diff --git a/internal/mobius/account_manager.go b/internal/mobius/account_manager.go
index 6bbc951..d9169c9 100644
--- a/internal/mobius/account_manager.go
+++ b/internal/mobius/account_manager.go
@@ -18,12 +18,14 @@ func loadFromYAMLFile(path string, data interface{}) error {
if err != nil {
return err
}
- defer fh.Close()
+ defer func() { _ = fh.Close() }()
decoder := yaml.NewDecoder(fh)
return decoder.Decode(data)
}
+// YAMLAccountManager implements AccountManager interface using YAML files for persistence.
+// It maintains an in-memory cache of accounts and synchronizes with YAML files on disk.
type YAMLAccountManager struct {
accounts map[string]hotline.Account
accountDir string
@@ -31,13 +33,16 @@ type YAMLAccountManager struct {
mu sync.Mutex
}
+// NewYAMLAccountManager creates a new YAML-based account manager that loads existing
+// accounts from .yaml files in the specified directory. It also performs migration
+// from old access flag format to new AccessBitmap format when needed.
func NewYAMLAccountManager(accountDir string) (*YAMLAccountManager, error) {
accountMgr := YAMLAccountManager{
accountDir: accountDir,
accounts: make(map[string]hotline.Account),
}
- matches, err := filepath.Glob(filepath.Join(accountDir, "*.yaml"))
+ matches, err := filepath.Glob(path.Join(accountDir, "*.yaml"))
if err != nil {
return nil, fmt.Errorf("list account files: %w", err)
}
@@ -71,19 +76,21 @@ func NewYAMLAccountManager(accountDir string) (*YAMLAccountManager, error) {
return &accountMgr, nil
}
+// Create adds a new account by writing it to a YAML file and updating the in-memory cache.
+// Returns an error if an account with the same login already exists.
func (am *YAMLAccountManager) Create(account hotline.Account) error {
am.mu.Lock()
defer am.mu.Unlock()
// Create account file, returning an error if one already exists.
file, err := os.OpenFile(
- filepath.Join(am.accountDir, path.Join("/", account.Login+".yaml")),
+ path.Join(am.accountDir, path.Join("/", account.Login+".yaml")),
os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644,
)
if err != nil {
return fmt.Errorf("create account file: %w", err)
}
- defer file.Close()
+ defer func() { _ = file.Close() }()
b, err := yaml.Marshal(account)
if err != nil {
@@ -100,6 +107,8 @@ func (am *YAMLAccountManager) Create(account hotline.Account) error {
return nil
}
+// Update modifies an existing account with new data and optionally renames it.
+// If newLogin differs from account.Login, the account file is renamed accordingly.
func (am *YAMLAccountManager) Update(account hotline.Account, newLogin string) error {
am.mu.Lock()
defer am.mu.Unlock()
@@ -107,17 +116,16 @@ func (am *YAMLAccountManager) Update(account hotline.Account, newLogin string) e
// If the login has changed, rename the account file.
if account.Login != newLogin {
err := os.Rename(
- filepath.Join(am.accountDir, path.Join("/", account.Login)+".yaml"),
- filepath.Join(am.accountDir, path.Join("/", newLogin)+".yaml"),
+ path.Join(am.accountDir, path.Join("/", account.Login)+".yaml"),
+ path.Join(am.accountDir, path.Join("/", newLogin)+".yaml"),
)
if err != nil {
return fmt.Errorf("error renaming account file: %w", err)
}
+ delete(am.accounts, account.Login)
account.Login = newLogin
am.accounts[newLogin] = account
-
- delete(am.accounts, account.Login)
}
out, err := yaml.Marshal(&account)
@@ -125,7 +133,7 @@ func (am *YAMLAccountManager) Update(account hotline.Account, newLogin string) e
return err
}
- if err := os.WriteFile(filepath.Join(am.accountDir, newLogin+".yaml"), out, 0644); err != nil {
+ if err := os.WriteFile(path.Join(am.accountDir, newLogin+".yaml"), out, 0644); err != nil {
return fmt.Errorf("error writing account file: %w", err)
}
@@ -134,6 +142,8 @@ func (am *YAMLAccountManager) Update(account hotline.Account, newLogin string) e
return nil
}
+// Get retrieves an account by login from the in-memory cache.
+// Returns nil if the account is not found.
func (am *YAMLAccountManager) Get(login string) *hotline.Account {
am.mu.Lock()
defer am.mu.Unlock()
@@ -146,6 +156,7 @@ func (am *YAMLAccountManager) Get(login string) *hotline.Account {
return &account
}
+// List returns all accounts from the in-memory cache as a slice.
func (am *YAMLAccountManager) List() []hotline.Account {
am.mu.Lock()
defer am.mu.Unlock()
@@ -158,11 +169,12 @@ func (am *YAMLAccountManager) List() []hotline.Account {
return accounts
}
+// Delete removes an account by deleting its YAML file and removing it from the cache.
func (am *YAMLAccountManager) Delete(login string) error {
am.mu.Lock()
defer am.mu.Unlock()
- err := os.Remove(filepath.Join(am.accountDir, path.Join("/", login+".yaml")))
+ err := os.Remove(path.Join(am.accountDir, path.Join("/", login+".yaml")))
if err != nil {
return fmt.Errorf("delete account file: %v", err)
}
@@ -172,34 +184,40 @@ func (am *YAMLAccountManager) Delete(login string) error {
return nil
}
+// MockAccountManager provides a test double implementation of AccountManager using testify/mock.
type MockAccountManager struct {
mock.Mock
}
+// Create mocks the Create method for testing.
func (m *MockAccountManager) Create(account hotline.Account) error {
args := m.Called(account)
return args.Error(0)
}
+// Update mocks the Update method for testing.
func (m *MockAccountManager) Update(account hotline.Account, newLogin string) error {
args := m.Called(account, newLogin)
return args.Error(0)
}
+// Get mocks the Get method for testing.
func (m *MockAccountManager) Get(login string) *hotline.Account {
args := m.Called(login)
return args.Get(0).(*hotline.Account)
}
+// List mocks the List method for testing.
func (m *MockAccountManager) List() []hotline.Account {
args := m.Called()
return args.Get(0).([]hotline.Account)
}
+// Delete mocks the Delete method for testing.
func (m *MockAccountManager) Delete(login string) error {
args := m.Called(login)
diff --git a/internal/mobius/account_manager_test.go b/internal/mobius/account_manager_test.go
index fe834a4..b0bfd79 100644
--- a/internal/mobius/account_manager_test.go
+++ b/internal/mobius/account_manager_test.go
@@ -1,89 +1,403 @@
package mobius
import (
- "fmt"
"github.com/jhalter/mobius/hotline"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "os"
+ "path"
"testing"
)
-func TestNewYAMLAccountManager(t *testing.T) {
- type args struct {
- accountDir string
+// copyTestFiles copies test config files to a temporary directory
+func copyTestFiles(t *testing.T, srcDir, dstDir string) {
+ files := []string{"admin.yaml", "guest.yaml", "user-with-old-access-format.yaml"}
+
+ for _, file := range files {
+ srcPath := path.Join(srcDir, file)
+ dstPath := path.Join(dstDir, file)
+
+ data, err := os.ReadFile(srcPath)
+ require.NoError(t, err, "Failed to read %s", srcPath)
+
+ err = os.WriteFile(dstPath, data, 0644)
+ require.NoError(t, err, "Failed to write %s", dstPath)
}
- tests := []struct {
- name string
- args args
- want *YAMLAccountManager
- wantErr assert.ErrorAssertionFunc
- }{
- {
- name: "loads accounts from a directory",
- args: args{
- accountDir: "test/config/Users",
+}
+
+func TestNewYAMLAccountManager(t *testing.T) {
+ t.Run("loads accounts from a directory", func(t *testing.T) {
+ // Create temporary directory and copy test files
+ tempDir := t.TempDir()
+ copyTestFiles(t, "test/config/Users", tempDir)
+
+ // Use temp directory instead of original test directory
+ got, err := NewYAMLAccountManager(tempDir)
+ require.NoError(t, err)
+
+ assert.Equal(t,
+ &hotline.Account{
+ Name: "admin",
+ Login: "admin",
+ Password: "$2a$04$2itGEYx8C1N5bsfRSoC9JuonS3I4YfnyVPZHLSwp7kEInRX0yoB.a",
+ Access: hotline.AccessBitmap{0xff, 0xff, 0xef, 0xff, 0xff, 0x80, 0x00, 0x00},
+ },
+ got.Get("admin"),
+ )
+
+ assert.Equal(t,
+ &hotline.Account{
+ Login: "guest",
+ Name: "guest",
+ Password: "$2a$04$6Yq/TIlgjSD.FbARwtYs9ODnkHawonu1TJ5W2jJKfhnHwBIQTk./y",
+ Access: hotline.AccessBitmap{0x60, 0x70, 0x0c, 0x20, 0x03, 0x80, 0x00, 0x00},
+ },
+ got.Get("guest"),
+ )
+
+ assert.Equal(t,
+ &hotline.Account{
+ Login: "test-user",
+ Name: "Test User Name",
+ Password: "$2a$04$9P/jgLn1fR9TjSoWL.rKxuN6g.1TSpf2o6Hw.aaRuBwrWIJNwsKkS",
+ Access: hotline.AccessBitmap{0x7d, 0xf0, 0x0c, 0xef, 0xab, 0x80, 0x00, 0x00},
+ },
+ got.Get("test-user"),
+ )
+ })
+}
+
+func TestYAMLAccountManager_Delete(t *testing.T) {
+ t.Run("successful deletions", func(t *testing.T) {
+ type args struct {
+ login string
+ }
+ tests := []struct {
+ name string
+ args args
+ }{
+ {
+ name: "deletes existing guest account",
+ args: args{
+ login: "guest",
+ },
+ },
+ {
+ name: "deletes existing admin account",
+ args: args{
+ login: "admin",
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Create temporary directory and copy test files
+ tempDir := t.TempDir()
+ copyTestFiles(t, "test/config/Users", tempDir)
+
+ accountMgr, err := NewYAMLAccountManager(tempDir)
+ require.NoError(t, err)
+
+ // Get initial state
+ initialAccounts := accountMgr.List()
+ initialCount := len(initialAccounts)
+
+ // Verify account exists before deletion
+ account := accountMgr.Get(tt.args.login)
+ require.NotNilf(t, account, "Account %s should exist before deletion", tt.args.login)
+
+ // Perform deletion
+ err = accountMgr.Delete(tt.args.login)
+ assert.NoErrorf(t, err, "Delete(%v)", tt.args.login)
+
+ // Verify account no longer exists in memory
+ account = accountMgr.Get(tt.args.login)
+ assert.Nilf(t, account, "Delete(%v)", tt.args.login)
+
+ // Verify file was deleted
+ filePath := path.Join(tempDir, tt.args.login+".yaml")
+ _, fileErr := os.Stat(filePath)
+ assert.Truef(t, os.IsNotExist(fileErr), "Delete(%v) - file should be deleted", tt.args.login)
+
+ // Verify list count decreased
+ updatedAccounts := accountMgr.List()
+ expectedCount := initialCount - 1
+ assert.Equalf(t, expectedCount, len(updatedAccounts), "Delete(%v)", tt.args.login)
+
+ // Verify deleted account is not in the list
+ for _, account := range updatedAccounts {
+ assert.NotEqualf(t, tt.args.login, account.Login, "Delete(%v)", tt.args.login)
+ }
+ })
+ }
+ })
+
+ t.Run("error cases", func(t *testing.T) {
+ type args struct {
+ login string
+ }
+ tests := []struct {
+ name string
+ args args
+ }{
+ {
+ name: "returns error when deleting non-existent account",
+ args: args{
+ login: "nonexistent",
+ },
},
- want: &YAMLAccountManager{
- accountDir: "test/config/Users",
- accounts: map[string]hotline.Account{
- "admin": {
- Name: "admin",
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Create temporary directory and copy test files
+ tempDir := t.TempDir()
+ copyTestFiles(t, "test/config/Users", tempDir)
+
+ accountMgr, err := NewYAMLAccountManager(tempDir)
+ require.NoError(t, err)
+
+ // Perform deletion
+ err = accountMgr.Delete(tt.args.login)
+ assert.Errorf(t, err, "Delete(%v)", tt.args.login)
+ })
+ }
+ })
+}
+
+func TestYAMLAccountManager_Create(t *testing.T) {
+ t.Run("successful creation", func(t *testing.T) {
+ type args struct {
+ account hotline.Account
+ }
+ tests := []struct {
+ name string
+ args args
+ }{
+ {
+ name: "creates new account with valid data",
+ args: args{
+ account: hotline.Account{
+ Login: "newuser",
+ Name: "New User",
+ Password: "hashedpassword",
+ Access: hotline.AccessBitmap{0x60, 0x70, 0x0c, 0x20, 0x03, 0x80, 0x00, 0x00},
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tempDir := t.TempDir()
+ copyTestFiles(t, "test/config/Users", tempDir)
+
+ accountMgr, err := NewYAMLAccountManager(tempDir)
+ require.NoError(t, err)
+
+ initialCount := len(accountMgr.List())
+
+ err = accountMgr.Create(tt.args.account)
+ assert.NoErrorf(t, err, "Create(%v)", tt.args.account)
+
+ // Verify account exists in memory
+ retrievedAccount := accountMgr.Get(tt.args.account.Login)
+ assert.NotNilf(t, retrievedAccount, "Create(%v)", tt.args.account)
+ assert.Equalf(t, &tt.args.account, retrievedAccount, "Create(%v)", tt.args.account)
+
+ // Verify file was created
+ filePath := path.Join(tempDir, tt.args.account.Login+".yaml")
+ _, err = os.Stat(filePath)
+ assert.NoErrorf(t, err, "Create(%v) - file should exist", tt.args.account)
+
+ // Verify list count increased
+ assert.Equalf(t, initialCount+1, len(accountMgr.List()), "Create(%v)", tt.args.account)
+ })
+ }
+ })
+
+ t.Run("error cases", func(t *testing.T) {
+ type args struct {
+ account hotline.Account
+ }
+ tests := []struct {
+ name string
+ args args
+ }{
+ {
+ name: "returns error when creating account with existing login",
+ args: args{
+ account: hotline.Account{
Login: "admin",
- Password: "$2a$04$2itGEYx8C1N5bsfRSoC9JuonS3I4YfnyVPZHLSwp7kEInRX0yoB.a",
- Access: hotline.AccessBitmap{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00},
+ Name: "Duplicate Admin",
+ Password: "password",
+ Access: hotline.AccessBitmap{},
},
- "Test User Name": {
- Name: "test-user",
- Login: "Test User Name",
- Password: "$2a$04$9P/jgLn1fR9TjSoWL.rKxuN6g.1TSpf2o6Hw.aaRuBwrWIJNwsKkS",
- Access: hotline.AccessBitmap{0x7d, 0xf0, 0x0c, 0xef, 0xab, 0x80, 0x00, 0x00},
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tempDir := t.TempDir()
+ copyTestFiles(t, "test/config/Users", tempDir)
+
+ accountMgr, err := NewYAMLAccountManager(tempDir)
+ require.NoError(t, err)
+
+ err = accountMgr.Create(tt.args.account)
+ assert.Errorf(t, err, "Create(%v)", tt.args.account)
+ })
+ }
+ })
+}
+
+func TestYAMLAccountManager_Update(t *testing.T) {
+ t.Run("successful updates", func(t *testing.T) {
+ type args struct {
+ account hotline.Account
+ newLogin string
+ }
+ tests := []struct {
+ name string
+ args args
+ }{
+ {
+ name: "updates account without changing login",
+ args: args{
+ account: hotline.Account{
+ Login: "guest",
+ Name: "Updated Guest",
+ Password: "newpassword",
+ Access: hotline.AccessBitmap{0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88},
},
- "guest": {
- Name: "guest",
+ newLogin: "guest",
+ },
+ },
+ {
+ name: "updates account with login change",
+ args: args{
+ account: hotline.Account{
Login: "guest",
- Password: "$2a$04$6Yq/TIlgjSD.FbARwtYs9ODnkHawonu1TJ5W2jJKfhnHwBIQTk./y",
- Access: hotline.AccessBitmap{0x7d, 0xf0, 0x0c, 0xef, 0xab, 0x80, 0x00, 0x00},
+ Name: "Renamed Guest",
+ Password: "password",
+ Access: hotline.AccessBitmap{0x60, 0x70, 0x0c, 0x20, 0x03, 0x80, 0x00, 0x00},
},
+ newLogin: "renamedguest",
},
},
- wantErr: assert.NoError,
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tempDir := t.TempDir()
+ copyTestFiles(t, "test/config/Users", tempDir)
+
+ accountMgr, err := NewYAMLAccountManager(tempDir)
+ require.NoError(t, err)
+
+ err = accountMgr.Update(tt.args.account, tt.args.newLogin)
+ assert.NoErrorf(t, err, "Update(%v, %v)", tt.args.account, tt.args.newLogin)
+
+ // Verify updated account exists with new login
+ retrievedAccount := accountMgr.Get(tt.args.newLogin)
+ assert.NotNilf(t, retrievedAccount, "Update(%v, %v)", tt.args.account, tt.args.newLogin)
+
+ expectedAccount := tt.args.account
+ expectedAccount.Login = tt.args.newLogin
+ assert.Equalf(t, &expectedAccount, retrievedAccount, "Update(%v, %v)", tt.args.account, tt.args.newLogin)
+
+ // If login changed, verify old login no longer exists
+ if tt.args.account.Login != tt.args.newLogin {
+ oldAccount := accountMgr.Get(tt.args.account.Login)
+ assert.Nilf(t, oldAccount, "Update(%v, %v) - old login should not exist", tt.args.account, tt.args.newLogin)
+
+ // Verify old file was renamed
+ oldFilePath := path.Join(tempDir, tt.args.account.Login+".yaml")
+ _, err = os.Stat(oldFilePath)
+ assert.Truef(t, os.IsNotExist(err), "Update(%v, %v) - old file should not exist", tt.args.account, tt.args.newLogin)
+ }
+
+ // Verify new file exists
+ newFilePath := path.Join(tempDir, tt.args.newLogin+".yaml")
+ _, err = os.Stat(newFilePath)
+ assert.NoErrorf(t, err, "Update(%v, %v) - new file should exist", tt.args.account, tt.args.newLogin)
+ })
+ }
+ })
+}
+
+func TestYAMLAccountManager_Get(t *testing.T) {
+ type args struct {
+ login string
+ }
+ tests := []struct {
+ name string
+ args args
+ want *hotline.Account
+ }{
+ {
+ name: "returns existing account",
+ args: args{
+ login: "admin",
+ },
+ want: &hotline.Account{
+ Name: "admin",
+ Login: "admin",
+ Password: "$2a$04$2itGEYx8C1N5bsfRSoC9JuonS3I4YfnyVPZHLSwp7kEInRX0yoB.a",
+ Access: hotline.AccessBitmap{0xff, 0xff, 0xef, 0xff, 0xff, 0x80, 0x00, 0x00},
+ },
+ },
+ {
+ name: "returns nil for non-existent account",
+ args: args{
+ login: "nonexistent",
+ },
+ want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- got, err := NewYAMLAccountManager(tt.args.accountDir)
- if !tt.wantErr(t, err, fmt.Sprintf("NewYAMLAccountManager(%v)", tt.args.accountDir)) {
- return
- }
+ tempDir := t.TempDir()
+ copyTestFiles(t, "test/config/Users", tempDir)
- assert.Equal(t,
- &hotline.Account{
- Name: "admin",
- Login: "admin",
- Password: "$2a$04$2itGEYx8C1N5bsfRSoC9JuonS3I4YfnyVPZHLSwp7kEInRX0yoB.a",
- Access: hotline.AccessBitmap{0xff, 0xff, 0xef, 0xff, 0xff, 0x80, 0x00, 0x00},
- },
- got.Get("admin"),
- )
+ accountMgr, err := NewYAMLAccountManager(tempDir)
+ require.NoError(t, err)
- assert.Equal(t,
- &hotline.Account{
- Login: "guest",
- Name: "guest",
- Password: "$2a$04$6Yq/TIlgjSD.FbARwtYs9ODnkHawonu1TJ5W2jJKfhnHwBIQTk./y",
- Access: hotline.AccessBitmap{0x60, 0x70, 0x0c, 0x20, 0x03, 0x80, 0x00, 0x00},
- },
- got.Get("guest"),
- )
+ got := accountMgr.Get(tt.args.login)
+ assert.Equalf(t, tt.want, got, "Get(%v)", tt.args.login)
+ })
+ }
+}
- assert.Equal(t,
- &hotline.Account{
- Login: "test-user",
- Name: "Test User Name",
- Password: "$2a$04$9P/jgLn1fR9TjSoWL.rKxuN6g.1TSpf2o6Hw.aaRuBwrWIJNwsKkS",
- Access: hotline.AccessBitmap{0x7d, 0xf0, 0x0c, 0xef, 0xab, 0x80, 0x00, 0x00},
- },
- got.Get("test-user"),
- )
+func TestYAMLAccountManager_List(t *testing.T) {
+ tests := []struct {
+ name string
+ wantCount int
+ wantLogins []string
+ }{
+ {
+ name: "returns all accounts",
+ wantCount: 3,
+ wantLogins: []string{"admin", "guest", "test-user"},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tempDir := t.TempDir()
+ copyTestFiles(t, "test/config/Users", tempDir)
+
+ accountMgr, err := NewYAMLAccountManager(tempDir)
+ require.NoError(t, err)
+
+ got := accountMgr.List()
+ assert.Equalf(t, tt.wantCount, len(got), "List() count")
+
+ // Check that all expected logins are present
+ gotLogins := make([]string, len(got))
+ for i, account := range got {
+ gotLogins[i] = account.Login
+ }
+
+ for _, expectedLogin := range tt.wantLogins {
+ assert.Containsf(t, gotLogins, expectedLogin, "List() should contain login %s", expectedLogin)
+ }
})
}
}
diff --git a/internal/mobius/api.go b/internal/mobius/api.go
index 31755b8..2bce8f8 100644
--- a/internal/mobius/api.go
+++ b/internal/mobius/api.go
@@ -2,12 +2,16 @@ package mobius
import (
"bytes"
+ "context"
"encoding/json"
- "github.com/jhalter/mobius/hotline"
"io"
"log"
"log/slog"
"net/http"
+ "strings"
+
+ "github.com/jhalter/mobius/hotline"
+ "github.com/redis/go-redis/v9"
)
type logResponseWriter struct {
@@ -30,35 +34,234 @@ func (lrw *logResponseWriter) Write(b []byte) (int, error) {
return lrw.ResponseWriter.Write(b)
}
+// APIServer provides REST API endpoints for managing a Hotline server.
+// It supports user management, banning operations, and server administration.
type APIServer struct {
hlServer *hotline.Server
logger *slog.Logger
mux *http.ServeMux
+ apiKey string
+ redis *redis.Client
+}
+
+func (srv *APIServer) authMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if srv.apiKey != "" && r.Header.Get("X-API-Key") != srv.apiKey {
+ w.WriteHeader(http.StatusUnauthorized)
+ _, _ = w.Write([]byte(`{"error":"unauthorized"}`))
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
}
func (srv *APIServer) logMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
lrw := NewLogResponseWriter(w)
next.ServeHTTP(lrw, r)
-
srv.logger.Info("req", "method", r.Method, "url", r.URL.Path, "remoteAddr", r.RemoteAddr, "response_code", lrw.statusCode)
})
}
-func NewAPIServer(hlServer *hotline.Server, reloadFunc func(), logger *slog.Logger) *APIServer {
+// NewAPIServer creates a new APIServer instance with the specified configuration.
+// It sets up all API routes and middleware, and optionally connects to Redis for persistent storage.
+func NewAPIServer(hlServer *hotline.Server, reloadFunc func(), logger *slog.Logger, apiKey string, redisAddr string, redisPassword string, redisDB int) *APIServer {
srv := APIServer{
hlServer: hlServer,
logger: logger,
mux: http.NewServeMux(),
+ apiKey: apiKey,
+ }
+ if redisAddr != "" {
+ srv.redis = redis.NewClient(&redis.Options{
+ Addr: redisAddr,
+ Password: redisPassword,
+ DB: redisDB,
+ })
+ hlServer.Redis = srv.redis
}
- srv.mux.Handle("/api/v1/reload", srv.logMiddleware(http.HandlerFunc(srv.ReloadHandler(reloadFunc))))
- srv.mux.Handle("/api/v1/shutdown", srv.logMiddleware(http.HandlerFunc(srv.ShutdownHandler)))
- srv.mux.Handle("/api/v1/stats", srv.logMiddleware(http.HandlerFunc(srv.RenderStats)))
+ srv.mux.Handle("/api/v1/online", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.OnlineHandler))))
+ srv.mux.Handle("/api/v1/ban", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.BanHandler))))
+ srv.mux.Handle("/api/v1/unban", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.UnbanHandler))))
+ srv.mux.Handle("/api/v1/banned/ips", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.ListBannedIPsHandler))))
+ srv.mux.Handle("/api/v1/banned/usernames", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.ListBannedUsernamesHandler))))
+ srv.mux.Handle("/api/v1/banned/nicknames", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.ListBannedNicknamesHandler))))
+ srv.mux.Handle("/api/v1/reload", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.ReloadHandler(reloadFunc)))))
+ srv.mux.Handle("/api/v1/shutdown", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.ShutdownHandler))))
+ srv.mux.Handle("/api/v1/stats", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.RenderStats))))
+
+ if srv.redis != nil {
+ if err := srv.redis.Del(context.Background(), "mobius:online").Err(); err != nil {
+ srv.logger.Warn("Failed to clear mobius:online in Redis", "err", err)
+ } else {
+ srv.logger.Info("Cleared mobius:online in Redis on startup")
+ }
+ }
return &srv
}
+// OnlineHandler returns a list of currently online users with their login, nickname, and IP address.
+// GET /api/v1/online
+func (srv *APIServer) OnlineHandler(w http.ResponseWriter, r *http.Request) {
+ var users []map[string]string
+
+ if srv.redis != nil {
+ members, err := srv.redis.SMembers(r.Context(), "mobius:online").Result()
+ if err == nil {
+ for _, m := range members {
+ parts := strings.SplitN(m, ":", 3)
+ if len(parts) == 3 {
+ users = append(users, map[string]string{
+ "login": parts[0],
+ "nickname": parts[1],
+ "ip": parts[2],
+ })
+ }
+ }
+ }
+ } else {
+ for _, c := range srv.hlServer.ClientMgr.List() {
+ users = append(users, map[string]string{
+ "login": string(c.Account.Login),
+ "nickname": string(c.UserName),
+ "ip": c.RemoteAddr,
+ })
+ }
+ }
+
+ _ = json.NewEncoder(w).Encode(users)
+}
+
+// BanRequest represents a ban/unban request payload.
+// At least one field (Username, Nickname, or IP) must be provided.
+type BanRequest struct {
+ Username string `json:"username,omitempty"`
+ Nickname string `json:"nickname,omitempty"`
+ IP string `json:"ip,omitempty"`
+}
+
+// BanHandler bans a user by username, nickname, or IP address.
+// The user will be disconnected if currently online and added to the ban list.
+// POST /api/v1/ban
+func (srv *APIServer) BanHandler(w http.ResponseWriter, r *http.Request) {
+ var req BanRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "invalid request", http.StatusBadRequest)
+ return
+ }
+
+ if req.Username == "" && req.Nickname == "" && req.IP == "" {
+ http.Error(w, "username, nickname, or ip required", http.StatusBadRequest)
+ return
+ }
+
+ if srv.redis != nil {
+ if req.Username != "" {
+ srv.redis.SAdd(r.Context(), "mobius:banned:users", req.Username)
+ }
+ if req.Nickname != "" {
+ srv.redis.SAdd(r.Context(), "mobius:banned:nicknames", req.Nickname)
+ }
+ if req.IP != "" {
+ srv.redis.SAdd(r.Context(), "mobius:banned:ips", req.IP)
+ }
+ } else {
+ // TODO: Fallback
+ }
+
+ // Disconnect user if online
+ for _, c := range srv.hlServer.ClientMgr.List() {
+ if (req.Username != "" && string(c.Account.Login) == req.Username) ||
+ (req.Nickname != "" && string(c.UserName) == req.Nickname) ||
+ (req.IP != "" && c.RemoteAddr == req.IP) {
+ c.Disconnect()
+ }
+ }
+
+ _, _ = w.Write([]byte(`{"msg":"banned"}`))
+}
+
+// UnbanHandler removes a ban for a user by username, nickname, or IP address.
+// POST /api/v1/unban
+func (srv *APIServer) UnbanHandler(w http.ResponseWriter, r *http.Request) {
+ var req BanRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "invalid request", http.StatusBadRequest)
+ return
+ }
+
+ if req.Username == "" && req.Nickname == "" && req.IP == "" {
+ http.Error(w, "username, nickname, or ip required", http.StatusBadRequest)
+ return
+ }
+
+ if srv.redis != nil {
+ if req.Username != "" {
+ srv.redis.SRem(r.Context(), "mobius:banned:users", req.Username)
+ }
+ if req.Nickname != "" {
+ srv.redis.SRem(r.Context(), "mobius:banned:nicknames", req.Nickname)
+ }
+ if req.IP != "" {
+ srv.redis.SRem(r.Context(), "mobius:banned:ips", req.IP)
+ }
+ } else {
+ // TODO: Fallback
+ }
+
+ _, _ = w.Write([]byte(`{"msg":"unbanned"}`))
+}
+
+// ListBannedIPsHandler returns a list of all banned IP addresses.
+// GET /api/v1/banned/ips
+func (srv *APIServer) ListBannedIPsHandler(w http.ResponseWriter, r *http.Request) {
+ if srv.redis != nil {
+ ips, err := srv.redis.SMembers(r.Context(), "mobius:banned:ips").Result()
+ if err != nil {
+ http.Error(w, "failed to fetch banned IPs", http.StatusInternalServerError)
+ return
+ }
+ _ = json.NewEncoder(w).Encode(ips)
+ } else {
+ // TODO: Fallback
+ }
+}
+
+// ListBannedUsernamesHandler returns a list of all banned usernames.
+// GET /api/v1/banned/usernames
+func (srv *APIServer) ListBannedUsernamesHandler(w http.ResponseWriter, r *http.Request) {
+ if srv.redis != nil {
+ users, err := srv.redis.SMembers(r.Context(), "mobius:banned:users").Result()
+ if err != nil {
+ http.Error(w, "failed to fetch banned usernames", http.StatusInternalServerError)
+ return
+ }
+ _ = json.NewEncoder(w).Encode(users)
+ } else {
+ // TODO: Fallback
+ }
+}
+
+// ListBannedNicknamesHandler returns a list of all banned nicknames.
+// GET /api/v1/banned/nicknames
+func (srv *APIServer) ListBannedNicknamesHandler(w http.ResponseWriter, r *http.Request) {
+ if srv.redis != nil {
+ nicks, err := srv.redis.SMembers(r.Context(), "mobius:banned:nicknames").Result()
+ if err != nil {
+ http.Error(w, "failed to fetch banned nicknames", http.StatusInternalServerError)
+ return
+ }
+ _ = json.NewEncoder(w).Encode(nicks)
+ } else {
+ // TODO: Fallback
+ }
+}
+
+// ShutdownHandler gracefully shuts down the server with a custom message.
+// The message is sent to all connected clients before shutdown.
+// POST /api/v1/shutdown
func (srv *APIServer) ShutdownHandler(w http.ResponseWriter, r *http.Request) {
msg, err := io.ReadAll(r.Body)
if err != nil || len(msg) == 0 {
@@ -71,6 +274,8 @@ func (srv *APIServer) ShutdownHandler(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `{ "msg": "server shutting down" }`)
}
+// ReloadHandler triggers a reload of the server configuration.
+// POST /api/v1/reload
func (srv *APIServer) ReloadHandler(reloadFunc func()) func(w http.ResponseWriter, _ *http.Request) {
return func(w http.ResponseWriter, _ *http.Request) {
reloadFunc()
@@ -79,15 +284,20 @@ func (srv *APIServer) ReloadHandler(reloadFunc func()) func(w http.ResponseWrite
}
}
+// RenderStats returns current server statistics and metrics in JSON format.
+// GET /api/v1/stats
func (srv *APIServer) RenderStats(w http.ResponseWriter, _ *http.Request) {
u, err := json.Marshal(srv.hlServer.CurrentStats())
if err != nil {
- panic(err)
+ http.Error(w, "failed to marshal stats", http.StatusInternalServerError)
+ return
}
- _, _ = io.WriteString(w, string(u))
+ _, _ = w.Write(u)
}
+// Serve starts the API server on the specified port.
+// This is a blocking call that will run until the server is shut down.
func (srv *APIServer) Serve(port string) {
err := http.ListenAndServe(port, srv.mux)
if err != nil {
diff --git a/internal/mobius/ban.go b/internal/mobius/ban.go
index e73b14a..b4fde95 100644
--- a/internal/mobius/ban.go
+++ b/internal/mobius/ban.go
@@ -4,7 +4,7 @@ import (
"fmt"
"gopkg.in/yaml.v3"
"os"
- "path/filepath"
+ "path"
"sync"
"time"
)
@@ -43,7 +43,7 @@ func (bf *BanFile) Load() error {
if err != nil {
return fmt.Errorf("open file: %v", err)
}
- defer fh.Close()
+ defer func() { _ = fh.Close() }()
err = yaml.NewDecoder(fh).Decode(&bf.banList)
if err != nil {
@@ -64,7 +64,7 @@ func (bf *BanFile) Add(ip string, until *time.Time) error {
return fmt.Errorf("marshal yaml: %v", err)
}
- err = os.WriteFile(filepath.Join(bf.filePath), out, 0644)
+ err = os.WriteFile(path.Join(bf.filePath), out, 0644)
if err != nil {
return fmt.Errorf("write file: %v", err)
}
diff --git a/internal/mobius/ban_test.go b/internal/mobius/ban_test.go
index f03f214..9f1f5d8 100644
--- a/internal/mobius/ban_test.go
+++ b/internal/mobius/ban_test.go
@@ -4,7 +4,7 @@ import (
"fmt"
"github.com/stretchr/testify/assert"
"os"
- "path/filepath"
+ "path"
"sync"
"testing"
"time"
@@ -26,9 +26,9 @@ func TestNewBanFile(t *testing.T) {
}{
{
name: "Valid path with valid content",
- args: args{path: filepath.Join(cwd, "test", "config", "Banlist.yaml")},
+ args: args{path: path.Join(cwd, "test", "config", "Banlist.yaml")},
want: &BanFile{
- filePath: filepath.Join(cwd, "test", "config", "Banlist.yaml"),
+ filePath: path.Join(cwd, "test", "config", "Banlist.yaml"),
banList: map[string]*time.Time{"192.168.86.29": &testTime},
},
wantErr: assert.NoError,
@@ -52,10 +52,10 @@ func TestAdd(t *testing.T) {
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
- defer os.RemoveAll(tmpDir) // Clean up the temporary directory.
+ defer func() { _ = os.RemoveAll(tmpDir) }() // Clean up the temporary directory.
// Path to the temporary ban file.
- tmpFilePath := filepath.Join(tmpDir, "banfile.yaml")
+ tmpFilePath := path.Join(tmpDir, "banfile.yaml")
// Initialize BanFile.
bf := &BanFile{
diff --git a/internal/mobius/config.go b/internal/mobius/config.go
index d04b14d..c798e54 100644
--- a/internal/mobius/config.go
+++ b/internal/mobius/config.go
@@ -7,6 +7,7 @@ import (
"gopkg.in/yaml.v3"
"os"
"path/filepath"
+ "strings"
)
var ConfigSearchOrder = []string{
@@ -28,7 +29,25 @@ func LoadConfig(path string) (*hotline.Config, error) {
}
validate := validator.New()
+ if err = validate.RegisterValidation("bannerext", func(fl validator.FieldLevel) bool {
+ filename := fl.Field().String()
+ if filename == "" {
+ return true // Allow empty since BannerFile is optional
+ }
+ ext := strings.ToLower(filepath.Ext(filename))
+ return ext == ".jpg" || ext == ".jpeg" || ext == ".gif"
+ }); err != nil {
+ return nil, fmt.Errorf("register validation: %v", err)
+ }
if err = validate.Struct(config); err != nil {
+ // Check if this is a BannerFile validation error and provide a better message
+ if validationErrs, ok := err.(validator.ValidationErrors); ok {
+ for _, fieldErr := range validationErrs {
+ if fieldErr.Field() == "BannerFile" && fieldErr.Tag() == "bannerext" {
+ return nil, fmt.Errorf("BannerFile must have a .jpg, .jpeg, or .gif extension (got: %s)", config.BannerFile)
+ }
+ }
+ }
return nil, fmt.Errorf("validate config: %v", err)
}
diff --git a/internal/mobius/config_test.go b/internal/mobius/config_test.go
new file mode 100644
index 0000000..b76b16d
--- /dev/null
+++ b/internal/mobius/config_test.go
@@ -0,0 +1,95 @@
+package mobius
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestLoadConfig_InvalidBannerFileExtension(t *testing.T) {
+ // Create a temporary directory for test files
+ tmpDir, err := os.MkdirTemp("", "mobius-config-test")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ // Create a test config file with an invalid banner file extension
+ configContent := `
+Name: "Test Server"
+Description: "Test Description"
+BannerFile: "banner.png"
+FileRoot: "files"
+`
+ configPath := filepath.Join(tmpDir, "config.yaml")
+ if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
+ t.Fatalf("Failed to write config file: %v", err)
+ }
+
+ // Attempt to load the config
+ _, err = LoadConfig(configPath)
+
+ // Verify that we get the improved error message
+ if err == nil {
+ t.Fatal("Expected error for invalid banner file extension, got nil")
+ }
+
+ expectedMsg := "BannerFile must have a .jpg, .jpeg, or .gif extension (got: banner.png)"
+ if !strings.Contains(err.Error(), expectedMsg) {
+ t.Errorf("Expected error message to contain %q, got: %v", expectedMsg, err)
+ }
+}
+
+func TestLoadConfig_ValidBannerFileExtensions(t *testing.T) {
+ tests := []struct {
+ name string
+ bannerFile string
+ }{
+ {"jpg extension", "banner.jpg"},
+ {"jpeg extension", "banner.jpeg"},
+ {"gif extension", "banner.gif"},
+ {"empty banner", ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Create a temporary directory for test files
+ tmpDir, err := os.MkdirTemp("", "mobius-config-test")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ // Create files subdirectory
+ filesDir := filepath.Join(tmpDir, "files")
+ if err := os.Mkdir(filesDir, 0755); err != nil {
+ t.Fatalf("Failed to create files dir: %v", err)
+ }
+
+ // Create a test config file with a valid banner file extension
+ configContent := `
+Name: "Test Server"
+Description: "Test Description"
+BannerFile: "` + tt.bannerFile + `"
+FileRoot: "files"
+`
+ configPath := filepath.Join(tmpDir, "config.yaml")
+ if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
+ t.Fatalf("Failed to write config file: %v", err)
+ }
+
+ // Attempt to load the config
+ config, err := LoadConfig(configPath)
+
+ // Verify that we don't get a validation error
+ if err != nil {
+ t.Errorf("Expected no error for %s, got: %v", tt.bannerFile, err)
+ }
+
+ if config.BannerFile != tt.bannerFile {
+ t.Errorf("Expected BannerFile to be %q, got %q", tt.bannerFile, config.BannerFile)
+ }
+ })
+ }
+}
diff --git a/internal/mobius/news.go b/internal/mobius/news.go
index 13a728a..c63c7f7 100644
--- a/internal/mobius/news.go
+++ b/internal/mobius/news.go
@@ -66,6 +66,7 @@ func (f *FlatNews) Write(p []byte) (int, error) {
f.mu.Lock()
defer f.mu.Unlock()
+ // Prepend the new post to the existing news posts.
f.data = slices.Concat(p, f.data)
tempFilePath := f.filePath + ".tmp"
@@ -79,10 +80,13 @@ func (f *FlatNews) Write(p []byte) (int, error) {
return 0, fmt.Errorf("rename temporary file to final file: %v", err)
}
- return len(p), os.WriteFile(f.filePath, f.data, 0644)
+ return len(p), nil
}
func (f *FlatNews) Seek(offset int64, _ int) (int64, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+
f.readOffset = int(offset)
return 0, nil
diff --git a/internal/mobius/news_test.go b/internal/mobius/news_test.go
new file mode 100644
index 0000000..b5d5ccd
--- /dev/null
+++ b/internal/mobius/news_test.go
@@ -0,0 +1,500 @@
+package mobius
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+)
+
+func TestNewFlatNews(t *testing.T) {
+ tests := []struct {
+ name string
+ setupFile func(string) error
+ filePath string
+ wantErr bool
+ wantErrMsg string
+ }{
+ {
+ name: "valid file with content",
+ setupFile: func(path string) error {
+ return os.WriteFile(path, []byte("test news content\nwith newlines"), 0644)
+ },
+ filePath: "test_news.txt",
+ wantErr: false,
+ },
+ {
+ name: "valid empty file",
+ setupFile: func(path string) error {
+ return os.WriteFile(path, []byte(""), 0644)
+ },
+ filePath: "empty_news.txt",
+ wantErr: false,
+ },
+ {
+ name: "nonexistent file",
+ setupFile: func(path string) error { return nil },
+ filePath: "nonexistent.txt",
+ wantErr: true,
+ wantErrMsg: "reload:",
+ },
+ {
+ name: "file with mixed line endings",
+ setupFile: func(path string) error {
+ return os.WriteFile(path, []byte("line1\nline2\r\nline3\r"), 0644)
+ },
+ filePath: "mixed_endings.txt",
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tempDir := t.TempDir()
+ fullPath := filepath.Join(tempDir, tt.filePath)
+
+ if err := tt.setupFile(fullPath); err != nil {
+ t.Fatalf("Failed to setup test file: %v", err)
+ }
+
+ flatNews, err := NewFlatNews(fullPath)
+
+ if tt.wantErr {
+ if err == nil {
+ t.Error("Expected error but got none")
+ } else if tt.wantErrMsg != "" && !containsSubstring(err.Error(), tt.wantErrMsg) {
+ t.Errorf("Expected error to contain %q, got %q", tt.wantErrMsg, err.Error())
+ }
+ return
+ }
+
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ return
+ }
+
+ if flatNews == nil {
+ t.Error("Expected FlatNews instance but got nil")
+ return
+ }
+
+ if flatNews.filePath != fullPath {
+ t.Errorf("Expected filePath %q, got %q", fullPath, flatNews.filePath)
+ }
+ })
+ }
+}
+
+func TestFlatNews_Reload(t *testing.T) {
+ tests := []struct {
+ name string
+ initialData string
+ newData string
+ expectData string
+ wantErr bool
+ deleteFile bool
+ }{
+ {
+ name: "reload with new content",
+ initialData: "initial content",
+ newData: "new content\nwith newlines",
+ expectData: "new content\rwith newlines",
+ wantErr: false,
+ },
+ {
+ name: "reload with empty content",
+ initialData: "some content",
+ newData: "",
+ expectData: "",
+ wantErr: false,
+ },
+ {
+ name: "reload with mixed line endings",
+ initialData: "old",
+ newData: "line1\nline2\r\nline3\r",
+ expectData: "line1\rline2\r\rline3\r",
+ wantErr: false,
+ },
+ {
+ name: "reload after file deletion",
+ initialData: "content",
+ newData: "",
+ expectData: "",
+ wantErr: true,
+ deleteFile: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tempDir := t.TempDir()
+ filePath := filepath.Join(tempDir, "test.txt")
+
+ if err := os.WriteFile(filePath, []byte(tt.initialData), 0644); err != nil {
+ t.Fatalf("Failed to create initial file: %v", err)
+ }
+
+ flatNews, err := NewFlatNews(filePath)
+ if err != nil {
+ t.Fatalf("Failed to create FlatNews: %v", err)
+ }
+
+ if tt.deleteFile {
+ if err := os.Remove(filePath); err != nil {
+ t.Fatalf("Failed to delete file: %v", err)
+ }
+ } else {
+ if err := os.WriteFile(filePath, []byte(tt.newData), 0644); err != nil {
+ t.Fatalf("Failed to write new data: %v", err)
+ }
+ }
+
+ err = flatNews.Reload()
+
+ if tt.wantErr {
+ if err == nil {
+ t.Error("Expected error but got none")
+ }
+ return
+ }
+
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ return
+ }
+
+ if string(flatNews.data) != tt.expectData {
+ t.Errorf("Expected data %q, got %q", tt.expectData, string(flatNews.data))
+ }
+ })
+ }
+}
+
+func TestFlatNews_Read(t *testing.T) {
+ tests := []struct {
+ name string
+ fileContent string
+ bufferSize int
+ expectedReads []readResult
+ }{
+ {
+ name: "read all at once",
+ fileContent: "test content\nwith newlines",
+ bufferSize: 100,
+ expectedReads: []readResult{
+ {data: "test content\rwith newlines", n: 26, err: nil},
+ {data: "", n: 0, err: io.EOF},
+ },
+ },
+ {
+ name: "read in chunks",
+ fileContent: "hello world",
+ bufferSize: 5,
+ expectedReads: []readResult{
+ {data: "hello", n: 5, err: nil},
+ {data: " worl", n: 5, err: nil},
+ {data: "d", n: 1, err: nil},
+ {data: "", n: 0, err: io.EOF},
+ },
+ },
+ {
+ name: "read empty file",
+ fileContent: "",
+ bufferSize: 10,
+ expectedReads: []readResult{
+ {data: "", n: 0, err: io.EOF},
+ },
+ },
+ {
+ name: "small buffer large content",
+ fileContent: "abcdefghij",
+ bufferSize: 3,
+ expectedReads: []readResult{
+ {data: "abc", n: 3, err: nil},
+ {data: "def", n: 3, err: nil},
+ {data: "ghi", n: 3, err: nil},
+ {data: "j", n: 1, err: nil},
+ {data: "", n: 0, err: io.EOF},
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tempDir := t.TempDir()
+ filePath := filepath.Join(tempDir, "test.txt")
+
+ if err := os.WriteFile(filePath, []byte(tt.fileContent), 0644); err != nil {
+ t.Fatalf("Failed to create test file: %v", err)
+ }
+
+ flatNews, err := NewFlatNews(filePath)
+ if err != nil {
+ t.Fatalf("Failed to create FlatNews: %v", err)
+ }
+
+ for i, expected := range tt.expectedReads {
+ buf := make([]byte, tt.bufferSize)
+ n, err := flatNews.Read(buf)
+
+ if err != expected.err {
+ t.Errorf("Read %d: expected error %v, got %v", i, expected.err, err)
+ }
+
+ if n != expected.n {
+ t.Errorf("Read %d: expected n %d, got %d", i, expected.n, n)
+ }
+
+ actualData := string(buf[:n])
+ if actualData != expected.data {
+ t.Errorf("Read %d: expected data %q, got %q", i, expected.data, actualData)
+ }
+ }
+ })
+ }
+}
+
+func TestFlatNews_Write(t *testing.T) {
+ tests := []struct {
+ name string
+ initialData string
+ writeData string
+ expectedData string
+ wantErr bool
+ }{
+ {
+ name: "write to empty file",
+ initialData: "",
+ writeData: "new content",
+ expectedData: "new content",
+ wantErr: false,
+ },
+ {
+ name: "prepend to existing content",
+ initialData: "existing",
+ writeData: "new ",
+ expectedData: "new existing",
+ wantErr: false,
+ },
+ {
+ name: "write empty data",
+ initialData: "content",
+ writeData: "",
+ expectedData: "content",
+ wantErr: false,
+ },
+ {
+ name: "write binary data",
+ initialData: "text",
+ writeData: "\x00\x01\x02",
+ expectedData: "\x00\x01\x02text",
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tempDir := t.TempDir()
+ filePath := filepath.Join(tempDir, "test.txt")
+
+ if err := os.WriteFile(filePath, []byte(tt.initialData), 0644); err != nil {
+ t.Fatalf("Failed to create initial file: %v", err)
+ }
+
+ flatNews, err := NewFlatNews(filePath)
+ if err != nil {
+ t.Fatalf("Failed to create FlatNews: %v", err)
+ }
+
+ n, err := flatNews.Write([]byte(tt.writeData))
+
+ if tt.wantErr {
+ if err == nil {
+ t.Error("Expected error but got none")
+ }
+ return
+ }
+
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ return
+ }
+
+ if n != len(tt.writeData) {
+ t.Errorf("Expected n %d, got %d", len(tt.writeData), n)
+ }
+
+ if string(flatNews.data) != tt.expectedData {
+ t.Errorf("Expected data %q, got %q", tt.expectedData, string(flatNews.data))
+ }
+
+ fileData, err := os.ReadFile(filePath)
+ if err != nil {
+ t.Errorf("Failed to read file: %v", err)
+ return
+ }
+
+ if string(fileData) != tt.expectedData {
+ t.Errorf("Expected file data %q, got %q", tt.expectedData, string(fileData))
+ }
+ })
+ }
+}
+
+func TestFlatNews_Seek(t *testing.T) {
+ tests := []struct {
+ name string
+ fileContent string
+ offset int64
+ whence int
+ expectOffset int64
+ expectErr bool
+ }{
+ {
+ name: "seek to beginning",
+ fileContent: "test content",
+ offset: 0,
+ whence: 0,
+ expectOffset: 0,
+ expectErr: false,
+ },
+ {
+ name: "seek to middle",
+ fileContent: "test content",
+ offset: 5,
+ whence: 0,
+ expectOffset: 0,
+ expectErr: false,
+ },
+ {
+ name: "seek beyond end",
+ fileContent: "test",
+ offset: 10,
+ whence: 0,
+ expectOffset: 0,
+ expectErr: false,
+ },
+ {
+ name: "negative offset",
+ fileContent: "test",
+ offset: -5,
+ whence: 0,
+ expectOffset: 0,
+ expectErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tempDir := t.TempDir()
+ filePath := filepath.Join(tempDir, "test.txt")
+
+ if err := os.WriteFile(filePath, []byte(tt.fileContent), 0644); err != nil {
+ t.Fatalf("Failed to create test file: %v", err)
+ }
+
+ flatNews, err := NewFlatNews(filePath)
+ if err != nil {
+ t.Fatalf("Failed to create FlatNews: %v", err)
+ }
+
+ offset, err := flatNews.Seek(tt.offset, tt.whence)
+
+ if tt.expectErr {
+ if err == nil {
+ t.Error("Expected error but got none")
+ }
+ return
+ }
+
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ return
+ }
+
+ if offset != tt.expectOffset {
+ t.Errorf("Expected offset %d, got %d", tt.expectOffset, offset)
+ }
+
+ expectedReadOffset := int(tt.offset)
+ if flatNews.readOffset != expectedReadOffset {
+ t.Errorf("Expected readOffset %d, got %d", expectedReadOffset, flatNews.readOffset)
+ }
+ })
+ }
+}
+
+func TestFlatNews_ConcurrentOperations(t *testing.T) {
+ tempDir := t.TempDir()
+ filePath := filepath.Join(tempDir, "concurrent_test.txt")
+
+ if err := os.WriteFile(filePath, []byte("initial content"), 0644); err != nil {
+ t.Fatalf("Failed to create test file: %v", err)
+ }
+
+ flatNews, err := NewFlatNews(filePath)
+ if err != nil {
+ t.Fatalf("Failed to create FlatNews: %v", err)
+ }
+
+ var wg sync.WaitGroup
+ errors := make(chan error, 10)
+
+ for i := 0; i < 5; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+
+ buf := make([]byte, 10)
+ _, err := flatNews.Read(buf)
+ if err != nil && err != io.EOF {
+ errors <- fmt.Errorf("read goroutine %d: %w", id, err)
+ }
+ }(i)
+ }
+
+ for i := 0; i < 3; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+
+ if err := flatNews.Reload(); err != nil {
+ errors <- fmt.Errorf("reload goroutine %d: %w", id, err)
+ }
+ }(i)
+ }
+
+ for i := 0; i < 2; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+
+ data := fmt.Sprintf("data%d", id)
+ if _, err := flatNews.Write([]byte(data)); err != nil {
+ errors <- fmt.Errorf("write goroutine %d: %w", id, err)
+ }
+ }(i)
+ }
+
+ wg.Wait()
+ close(errors)
+
+ for err := range errors {
+ t.Errorf("Concurrent operation error: %v", err)
+ }
+}
+
+type readResult struct {
+ data string
+ n int
+ err error
+}
+
+func containsSubstring(s, substr string) bool {
+ return len(s) >= len(substr) &&
+ (len(substr) == 0 ||
+ strings.Contains(s, substr))
+} \ No newline at end of file
diff --git a/internal/mobius/threaded_news.go b/internal/mobius/threaded_news.go
index e9d9c22..c7daea4 100644
--- a/internal/mobius/threaded_news.go
+++ b/internal/mobius/threaded_news.go
@@ -195,7 +195,7 @@ func (n *ThreadedNewsYAML) DeleteArticle(newsPath []string, articleID uint32, _
return n.writeFile()
}
-func (n *ThreadedNewsYAML) ListArticles(newsPath []string) hotline.NewsArtListData {
+func (n *ThreadedNewsYAML) ListArticles(newsPath []string) (hotline.NewsArtListData, error) {
n.mu.Lock()
defer n.mu.Unlock()
@@ -218,7 +218,7 @@ func (n *ThreadedNewsYAML) Load() error {
if err != nil {
return err
}
- defer fh.Close()
+ defer func() { _ = fh.Close() }()
n.ThreadedNews = hotline.ThreadedNews{}
diff --git a/internal/mobius/threaded_news_test.go b/internal/mobius/threaded_news_test.go
index dd06a28..2ff8a84 100644
--- a/internal/mobius/threaded_news_test.go
+++ b/internal/mobius/threaded_news_test.go
@@ -5,7 +5,7 @@ import (
"github.com/jhalter/mobius/hotline"
"github.com/stretchr/testify/assert"
"os"
- "path/filepath"
+ "path"
"sync"
"testing"
)
@@ -52,7 +52,7 @@ func TestLoadFromYAMLFile(t *testing.T) {
if tt.content != "" {
err := os.WriteFile(tt.fileName, []byte(tt.content), 0644)
assert.NoError(t, err)
- defer os.Remove(tt.fileName) // Cleanup the file after the test
+ defer func() { _ = os.Remove(tt.fileName) }() // Cleanup the file after the test
}
var data TestData
@@ -161,10 +161,10 @@ func TestThreadedNewsYAML_CreateGrouping(t *testing.T) {
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
- defer os.RemoveAll(tmpDir) // Clean up the temporary directory.
+ defer func() { _ = os.RemoveAll(tmpDir) }() // Clean up the temporary directory.
// Path to the temporary ban file.
- tmpFilePath := filepath.Join(tmpDir, "ThreadedNews.yaml")
+ tmpFilePath := path.Join(tmpDir, "ThreadedNews.yaml")
type fields struct {
ThreadedNews hotline.ThreadedNews
diff --git a/internal/mobius/transaction_handlers.go b/internal/mobius/transaction_handlers.go
index 6451212..9916b6c 100644
--- a/internal/mobius/transaction_handlers.go
+++ b/internal/mobius/transaction_handlers.go
@@ -3,17 +3,91 @@ package mobius
import (
"bufio"
"bytes"
+ "context"
"encoding/binary"
"fmt"
- "github.com/jhalter/mobius/hotline"
- "golang.org/x/text/encoding/charmap"
"io"
"math/big"
+ "net"
"os"
"path"
- "path/filepath"
"strings"
"time"
+
+ "github.com/jhalter/mobius/hotline"
+ "golang.org/x/text/encoding/charmap"
+)
+
+// Public error message constants for reuse by other packages
+const (
+ // Authorization error messages
+ ErrMsgNotAllowedParticipateChat = "You are not allowed to participate in chat."
+ ErrMsgNotAllowedSendPrivateMsg = "You are not allowed to send private messages."
+ ErrMsgNotAllowedReadNews = "You are not allowed to read news."
+ ErrMsgNotAllowedPostNews = "You are not allowed to post news."
+ ErrMsgNotAllowedCreateAccounts = "You are not allowed to create new accounts."
+ ErrMsgNotAllowedViewAccounts = "You are not allowed to view accounts."
+ ErrMsgNotAllowedModifyAccounts = "You are not allowed to modify accounts."
+ ErrMsgNotAllowedDeleteAccounts = "You are not allowed to delete accounts."
+ ErrMsgNotAllowedRequestPrivateChat = "You are not allowed to request private chat."
+ ErrMsgNotAllowedCreateNewsCategories = "You are not allowed to create news categories."
+ ErrMsgNotAllowedDeleteNewsArticles = "You are not allowed to delete news articles."
+ ErrMsgNotAllowedSetCommentsFiles = "You are not allowed to set comments for files."
+ ErrMsgNotAllowedSetCommentsFolders = "You are not allowed to set comments for folders."
+ ErrMsgNotAllowedRenameFiles = "You are not allowed to rename files."
+ ErrMsgNotAllowedRenameFolders = "You are not allowed to rename folders."
+ ErrMsgNotAllowedDeleteFiles = "You are not allowed to delete files."
+ ErrMsgNotAllowedDeleteFolders = "You are not allowed to delete folders."
+ ErrMsgNotAllowedMoveFiles = "You are not allowed to move files."
+ ErrMsgNotAllowedMoveFolders = "You are not allowed to move folders."
+ ErrMsgNotAllowedCreateFolders = "You are not allowed to create folders."
+ ErrMsgNotAllowedSendBroadcast = "You are not allowed to send broadcast messages."
+ ErrMsgNotAllowedGetClientInfo = "You are not allowed to get client info."
+ ErrMsgNotAllowedDisconnectUsers = "You are not allowed to disconnect users."
+ ErrMsgNotAllowedCreateNewsfolders = "You are not allowed to create news folders."
+ ErrMsgNotAllowedDeleteNewsCategories = "You are not allowed to delete news categories."
+ ErrMsgNotAllowedDeleteNewsFolders = "You are not allowed to delete news folders."
+ ErrMsgNotAllowedPostNewsArticles = "You are not allowed to post news articles."
+ ErrMsgNotAllowedDownloadFiles = "You are not allowed to download files."
+ ErrMsgNotAllowedDownloadFolders = "You are not allowed to download folders."
+ ErrMsgNotAllowedUploadFiles = "You are not allowed to upload files."
+ ErrMsgNotAllowedUploadFolders = "You are not allowed to upload folders."
+ ErrMsgNotAllowedViewDropBoxes = "You are not allowed to view drop boxes."
+ ErrMsgNotAllowedMakeAliases = "You are not allowed to make aliases."
+
+ // Account error messages
+ ErrMsgAccountDeleted = "You are logged in with an account which was deleted."
+ ErrMsgAccountExists = "Cannot create account because there is already an account with that login."
+ ErrMsgAccountMoreAccess = "Cannot create account with more access than yourself."
+ ErrMsgAccountNotExist = "Account does not exist."
+
+ // Account error templates (for dynamic content)
+ ErrMsgAccountExistsTemplate = "Cannot create account %s because there is already an account with that login."
+
+ // File operation error templates
+ ErrMsgCannotRenameFileNotFound = "Cannot rename file %s because it does not exist or cannot be found."
+ ErrMsgCannotRenameFolderNotFound = "Cannot rename folder %s because it does not exist or cannot be found."
+ ErrMsgCannotDeleteFileNotFound = "Cannot delete file %s because it does not exist or cannot be found."
+
+ // File operation error templates (for dynamic content)
+ ErrMsgFolderCreateConflictTemplate = "Cannot create folder \"%s\" because there is already a file or folder with that Name."
+ ErrMsgFolderCreateErrorTemplate = "Cannot create folder \"%s\" because an error occurred."
+
+ // Upload restriction templates (these need dynamic content)
+ ErrMsgUploadRestrictedTemplate = "Cannot accept upload of the %s \"%v\" because you are only allowed to upload to the \"Uploads\" folder."
+ ErrMsgFileUploadConflictTemplate = "Cannot accept upload because there is already a file named \"%v\". Try choosing a different Name."
+
+ // Chat/messaging templates (these need dynamic content)
+ ErrMsgDoesNotAcceptTemplate = "%s does not accept %s."
+
+ // Ban messages
+ ErrMsgTemporaryBan = "You are temporarily banned on this server"
+ ErrMsgPermanentBan = "You are permanently banned on this server"
+
+ // General error messages
+ ErrMsgAccountNotFound = "Account not found."
+ ErrMsgUserNotFound = "User not found."
+ ErrMsgCreateAlias = "Error creating alias"
)
// Converts bytes from Mac Roman encoding to UTF-8
@@ -69,9 +143,17 @@ func RegisterHandlers(srv *hotline.Server) {
srv.HandleFunc(hotline.TranDownloadBanner, HandleDownloadBanner)
}
+// HandleChatSend processes chat messages and distributes them to appropriate clients.
+//
+// Fields used in the request:
+// * 101 Data Required - Chat message content
+// * 109 Chat Options Optional - Set to [0,1] for /me formatted messages
+// * 114 Chat ID Optional - Private chat ID (omitted for public chat)
+//
+// Fields used in the reply:
func HandleChatSend(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessSendChat) {
- return cc.NewErrReply(t, "You are not allowed to participate in chat.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedParticipateChat)
}
// Truncate long usernames
@@ -139,7 +221,7 @@ func HandleChatSend(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotli
// None
func HandleSendInstantMsg(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessSendPrivMsg) {
- return cc.NewErrReply(t, "You are not allowed to send private messages.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedSendPrivateMsg)
}
msg := t.GetField(hotline.FieldData)
@@ -171,7 +253,7 @@ func HandleSendInstantMsg(cc *hotline.ClientConn, t *hotline.Transaction) (res [
hotline.NewTransaction(
hotline.TranServerMsg,
cc.ID,
- hotline.NewField(hotline.FieldData, []byte(string(otherClient.UserName)+" does not accept private messages.")),
+ hotline.NewField(hotline.FieldData, []byte(fmt.Sprintf(ErrMsgDoesNotAcceptTemplate, string(otherClient.UserName), "private messages"))),
hotline.NewField(hotline.FieldUserName, otherClient.UserName),
hotline.NewField(hotline.FieldUserID, otherClient.ID[:]),
hotline.NewField(hotline.FieldOptions, []byte{0, 2}),
@@ -200,6 +282,21 @@ func HandleSendInstantMsg(cc *hotline.ClientConn, t *hotline.Transaction) (res [
var fileTypeFLDR = [4]byte{0x66, 0x6c, 0x64, 0x72}
+// HandleGetFileInfo returns detailed information about a file or folder.
+//
+// Fields used in the request:
+// * 201 File Name Required - Name of the file or folder
+// * 202 File Path Optional - Path to the file or folder
+//
+// Fields used in the reply:
+// * 201 File Name File name (encoded)
+// * 205 File Type String Friendly file type description
+// * 206 File Creator String Friendly creator description
+// * 213 File Type File type signature
+// * 208 File Create Date File creation date
+// * 209 File Modify Date File modification date
+// * 210 File Comment Optional - File comment if present
+// * 207 File Size Optional - File size (only for files, not folders)
func HandleGetFileInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
fileName := t.GetField(hotline.FieldFileName).Data
filePath := t.GetField(hotline.FieldFilePath).Data
@@ -209,7 +306,7 @@ func HandleGetFileInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho
return res
}
- fw, err := hotline.NewFileWrapper(cc.Server.FS, fullFilePath, 0)
+ fw, err := hotline.NewFile(cc.Server.FS, fullFilePath, 0)
if err != nil {
return res
}
@@ -263,7 +360,7 @@ func HandleSetFileInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho
return res
}
- hlFile, err := hotline.NewFileWrapper(cc.Server.FS, fullFilePath, 0)
+ hlFile, err := hotline.NewFile(cc.Server.FS, fullFilePath, 0)
if err != nil {
return res
}
@@ -271,11 +368,11 @@ func HandleSetFileInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho
switch mode := fi.Mode(); {
case mode.IsDir():
if !cc.Authorize(hotline.AccessSetFolderComment) {
- return cc.NewErrReply(t, "You are not allowed to set comments for folders.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedSetCommentsFolders)
}
case mode.IsRegular():
if !cc.Authorize(hotline.AccessSetFileComment) {
- return cc.NewErrReply(t, "You are not allowed to set comments for files.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedSetCommentsFiles)
}
}
@@ -303,16 +400,16 @@ func HandleSetFileInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho
switch mode := fi.Mode(); {
case mode.IsDir():
if !cc.Authorize(hotline.AccessRenameFolder) {
- return cc.NewErrReply(t, "You are not allowed to rename folders.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedRenameFolders)
}
err = os.Rename(fullFilePath, fullNewFilePath)
if os.IsNotExist(err) {
- return cc.NewErrReply(t, "Cannot rename folder "+string(fileName)+" because it does not exist or cannot be found.")
+ return cc.NewErrReply(t, fmt.Sprintf(ErrMsgCannotRenameFolderNotFound, string(fileName)))
}
case mode.IsRegular():
if !cc.Authorize(hotline.AccessRenameFile) {
- return cc.NewErrReply(t, "You are not allowed to rename files.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedRenameFiles)
}
fileDir, err := hotline.ReadPath(cc.FileRoot(), filePath, []byte{})
if err != nil {
@@ -325,7 +422,7 @@ func HandleSetFileInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho
err = hlFile.Move(fileDir)
if os.IsNotExist(err) {
- return cc.NewErrReply(t, "Cannot rename file "+string(fileName)+" because it does not exist or cannot be found.")
+ return cc.NewErrReply(t, fmt.Sprintf(ErrMsgCannotRenameFileNotFound, string(fileName)))
}
if err != nil {
return res
@@ -358,24 +455,24 @@ func HandleDeleteFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
return res
}
- hlFile, err := hotline.NewFileWrapper(cc.Server.FS, fullFilePath, 0)
+ hlFile, err := hotline.NewFile(cc.Server.FS, fullFilePath, 0)
if err != nil {
return res
}
fi, err := hlFile.DataFile()
if err != nil {
- return cc.NewErrReply(t, "Cannot delete file "+string(fileName)+" because it does not exist or cannot be found.")
+ return cc.NewErrReply(t, fmt.Sprintf(ErrMsgCannotDeleteFileNotFound, string(fileName)))
}
switch mode := fi.Mode(); {
case mode.IsDir():
if !cc.Authorize(hotline.AccessDeleteFolder) && !fp.IsUserDir() {
- return cc.NewErrReply(t, "You are not allowed to delete folders.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedDeleteFolders)
}
case mode.IsRegular():
if !cc.Authorize(hotline.AccessDeleteFile) && !fp.IsUserDir() {
- return cc.NewErrReply(t, "You are not allowed to delete files.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedDeleteFiles)
}
}
@@ -403,23 +500,23 @@ func HandleMoveFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotli
cc.Logger.Info("Move file", "src", filePath+"/"+fileName, "dst", fileNewPath+"/"+fileName)
- hlFile, err := hotline.NewFileWrapper(cc.Server.FS, filePath, 0)
+ hlFile, err := hotline.NewFile(cc.Server.FS, filePath, 0)
if err != nil {
return res
}
fi, err := hlFile.DataFile()
if err != nil {
- return cc.NewErrReply(t, "Cannot delete file "+fileName+" because it does not exist or cannot be found.")
+ return cc.NewErrReply(t, fmt.Sprintf(ErrMsgCannotDeleteFileNotFound, fileName))
}
switch mode := fi.Mode(); {
case mode.IsDir():
if !cc.Authorize(hotline.AccessMoveFolder) {
- return cc.NewErrReply(t, "You are not allowed to move folders.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedMoveFolders)
}
case mode.IsRegular():
if !cc.Authorize(hotline.AccessMoveFile) {
- return cc.NewErrReply(t, "You are not allowed to move files.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedMoveFiles)
}
}
if err := hlFile.Move(fileNewPath); err != nil {
@@ -431,9 +528,17 @@ func HandleMoveFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotli
return res
}
+// HandleNewFolder creates a new folder at the specified path.
+//
+// Fields used in the request:
+// * 201 File Name Required - Name of the new folder
+// * 202 File Path Optional - Path where the folder should be created
+//
+// Fields used in the reply:
+// None
func HandleNewFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessCreateFolder) {
- return cc.NewErrReply(t, "You are not allowed to create folders.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedCreateFolders)
}
folderName := string(t.GetField(hotline.FieldFileName).Data)
@@ -450,7 +555,7 @@ func HandleNewFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotl
}
for _, pathItem := range newFp.Items {
- subPath = filepath.Join("/", subPath, string(pathItem.Name))
+ subPath = path.Join("/", subPath, string(pathItem.Name))
}
}
newFolderPath := path.Join(cc.FileRoot(), subPath, folderName)
@@ -462,21 +567,31 @@ func HandleNewFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotl
// TODO: check path and folder Name lengths
if _, err := cc.Server.FS.Stat(newFolderPath); !os.IsNotExist(err) {
- msg := fmt.Sprintf("Cannot create folder \"%s\" because there is already a file or folder with that Name.", folderName)
+ msg := fmt.Sprintf(ErrMsgFolderCreateConflictTemplate, folderName)
return cc.NewErrReply(t, msg)
}
if err := cc.Server.FS.Mkdir(newFolderPath, 0777); err != nil {
- msg := fmt.Sprintf("Cannot create folder \"%s\" because an error occurred.", folderName)
+ msg := fmt.Sprintf(ErrMsgFolderCreateErrorTemplate, folderName)
return cc.NewErrReply(t, msg)
}
return append(res, cc.NewReply(t))
}
+// HandleSetUser modifies an existing user account's properties.
+//
+// Fields used in the request:
+// * 105 User Login Required - Login name of the account to modify
+// * 102 User Name Required - Display name for the account
+// * 110 User Access Required - Access permissions bitmap
+// * 106 User Password Optional - New password (omitted to clear password)
+//
+// Fields used in the reply:
+// None
func HandleSetUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessModifyUser) {
- return cc.NewErrReply(t, "You are not allowed to modify accounts.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedModifyAccounts)
}
login := t.GetField(hotline.FieldUserLogin).DecodeObfuscatedString()
@@ -486,7 +601,7 @@ func HandleSetUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotlin
account := cc.Server.AccountManager.Get(login)
if account == nil {
- return cc.NewErrReply(t, "Account not found.")
+ return cc.NewErrReply(t, ErrMsgAccountNotFound)
}
account.Name = userName
copy(account.Access[:], newAccessLvl)
@@ -533,14 +648,24 @@ func HandleSetUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotlin
return append(res, cc.NewReply(t))
}
+// HandleGetUser retrieves account information for a specific user.
+//
+// Fields used in the request:
+// * 105 User Login Required - Login name of the account to retrieve
+//
+// Fields used in the reply:
+// * 102 User Name Account display name
+// * 105 User Login Account login name (encoded)
+// * 106 User Password Account password hash
+// * 110 User Access Access permissions bitmap
func HandleGetUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessOpenUser) {
- return cc.NewErrReply(t, "You are not allowed to view accounts.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedViewAccounts)
}
account := cc.Server.AccountManager.Get(string(t.GetField(hotline.FieldUserLogin).Data))
if account == nil {
- return cc.NewErrReply(t, "Account does not exist.")
+ return cc.NewErrReply(t, ErrMsgAccountNotExist)
}
return append(res, cc.NewReply(t,
@@ -551,9 +676,16 @@ func HandleGetUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotlin
))
}
+// HandleListUsers returns a list of all user accounts on the server.
+//
+// Fields used in the request:
+// None
+//
+// Fields used in the reply:
+// * 101 Data Repeated - Serialized account data for each user
func HandleListUsers(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessOpenUser) {
- return cc.NewErrReply(t, "You are not allowed to view accounts.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedViewAccounts)
}
var userFields []hotline.Field
@@ -579,6 +711,21 @@ func HandleListUsers(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotl
// The Transaction sent by the client includes one data field per user that was modified. This data field in turn
// contains another data field encoded in its payload with a varying number of sub fields depending on which action is
// performed. This seems to be the only place in the Hotline protocol where a data field contains another data field.
+// HandleUpdateUser processes batch user account operations from the v1.5+ multi-user editor.
+// This handler supports creating, deleting, and modifying multiple user accounts in a single transaction.
+//
+// Fields used in the request:
+// * 101 Data Repeated - Each contains encoded sub-fields for one user operation
+//
+// Sub-fields for user operations:
+// * 101 Data Optional - Original login name (for rename operations)
+// * 105 User Login Required - Login name (new name for renames)
+// * 102 User Name Optional - Display name (for create/modify)
+// * 106 User Password Optional - Password (for create/modify)
+// * 110 User Access Optional - Access permissions (for create/modify)
+//
+// Fields used in the reply:
+// None
func HandleUpdateUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
for _, field := range t.Fields {
var subFields []hotline.Field
@@ -600,7 +747,7 @@ func HandleUpdateUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
// If there's only one subfield, that indicates this is a delete operation for the login in FieldData
if len(subFields) == 1 {
if !cc.Authorize(hotline.AccessDeleteUser) {
- return cc.NewErrReply(t, "You are not allowed to delete accounts.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedDeleteAccounts)
}
login := string(hotline.EncodeString(hotline.GetField(hotline.FieldData, &subFields).Data))
@@ -618,7 +765,7 @@ func HandleUpdateUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
res = append(res,
hotline.NewTransaction(hotline.TranServerMsg, [2]byte{},
- hotline.NewField(hotline.FieldData, []byte("You are logged in with an account which was deleted.")),
+ hotline.NewField(hotline.FieldData, []byte(ErrMsgAccountDeleted)),
hotline.NewField(hotline.FieldChatOptions, []byte{0}),
),
)
@@ -658,7 +805,7 @@ func HandleUpdateUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
// Account exists, so this is an update action.
if !cc.Authorize(hotline.AccessModifyUser) {
- return cc.NewErrReply(t, "You are not allowed to modify accounts.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedModifyAccounts)
}
// This part is a bit tricky. There are three possibilities:
@@ -690,7 +837,7 @@ func HandleUpdateUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
}
} else {
if !cc.Authorize(hotline.AccessCreateUser) {
- return cc.NewErrReply(t, "You are not allowed to create new accounts.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedCreateAccounts)
}
cc.Logger.Info("CreateUser", "login", userLogin)
@@ -716,7 +863,7 @@ func HandleUpdateUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
err := cc.Server.AccountManager.Create(*account)
if err != nil {
- return cc.NewErrReply(t, "Cannot create account because there is already an account with that login.")
+ return cc.NewErrReply(t, ErrMsgAccountExists)
}
}
}
@@ -724,17 +871,26 @@ func HandleUpdateUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
return append(res, cc.NewReply(t))
}
-// HandleNewUser creates a new user account
+// HandleNewUser creates a new user account.
+//
+// Fields used in the request:
+// * 105 User Login Required - Login name for the new account
+// * 102 User Name Required - Display name for the account
+// * 106 User Password Required - Password for the account
+// * 110 User Access Required - Access permissions bitmap
+//
+// Fields used in the reply:
+// None
func HandleNewUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessCreateUser) {
- return cc.NewErrReply(t, "You are not allowed to create new accounts.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedCreateAccounts)
}
login := t.GetField(hotline.FieldUserLogin).DecodeObfuscatedString()
// If the account already exists, reply with an error.
if account := cc.Server.AccountManager.Get(login); account != nil {
- return cc.NewErrReply(t, "Cannot create account "+login+" because there is already an account with that login.")
+ return cc.NewErrReply(t, fmt.Sprintf(ErrMsgAccountExistsTemplate, login))
}
var newAccess hotline.AccessBitmap
@@ -744,7 +900,7 @@ func HandleNewUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotlin
for i := 0; i < 64; i++ {
if newAccess.IsSet(i) {
if !cc.Authorize(i) {
- return cc.NewErrReply(t, "Cannot create account with more access than yourself.")
+ return cc.NewErrReply(t, ErrMsgAccountMoreAccess)
}
}
}
@@ -753,15 +909,22 @@ func HandleNewUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotlin
err := cc.Server.AccountManager.Create(*account)
if err != nil {
- return cc.NewErrReply(t, "Cannot create account because there is already an account with that login.")
+ return cc.NewErrReply(t, ErrMsgAccountExists)
}
return append(res, cc.NewReply(t))
}
+// HandleDeleteUser deletes a user account and disconnects any logged-in sessions.
+//
+// Fields used in the request:
+// * 105 User Login Required - Login name of the account to delete
+//
+// Fields used in the reply:
+// None
func HandleDeleteUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessDeleteUser) {
- return cc.NewErrReply(t, "You are not allowed to delete accounts.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedDeleteAccounts)
}
login := t.GetField(hotline.FieldUserLogin).DecodeObfuscatedString()
@@ -775,7 +938,7 @@ func HandleDeleteUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
if client.Account.Login == login {
res = append(res,
hotline.NewTransaction(hotline.TranServerMsg, client.ID,
- hotline.NewField(hotline.FieldData, []byte("You are logged in with an account which was deleted.")),
+ hotline.NewField(hotline.FieldData, []byte(ErrMsgAccountDeleted)),
hotline.NewField(hotline.FieldChatOptions, []byte{2}),
),
)
@@ -790,10 +953,16 @@ func HandleDeleteUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
return append(res, cc.NewReply(t))
}
-// HandleUserBroadcast sends an Administrator Message to all connected clients of the server
+// HandleUserBroadcast sends an administrator message to all connected clients.
+//
+// Fields used in the request:
+// * 101 Data Required - Broadcast message content
+//
+// Fields used in the reply:
+// None
func HandleUserBroadcast(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessBroadcast) {
- return cc.NewErrReply(t, "You are not allowed to send broadcast messages.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedSendBroadcast)
}
cc.SendAll(
@@ -815,14 +984,14 @@ func HandleUserBroadcast(cc *hotline.ClientConn, t *hotline.Transaction) (res []
// 101 Data User info text string
func HandleGetClientInfoText(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessGetClientInfo) {
- return cc.NewErrReply(t, "You are not allowed to get client info.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedGetClientInfo)
}
clientID := t.GetField(hotline.FieldUserID).Data
clientConn := cc.Server.ClientMgr.Get(hotline.ClientID(clientID))
if clientConn == nil {
- return cc.NewErrReply(t, "User not found.")
+ return cc.NewErrReply(t, ErrMsgUserNotFound)
}
return append(res, cc.NewReply(t,
@@ -831,6 +1000,13 @@ func HandleGetClientInfoText(cc *hotline.ClientConn, t *hotline.Transaction) (re
))
}
+// HandleGetUserNameList returns a list of all currently connected users.
+//
+// Fields used in the request:
+// None
+//
+// Fields used in the reply:
+// * 300 Username With Info Repeated - User information for each connected client
func HandleGetUserNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
var fields []hotline.Field
for _, c := range cc.Server.ClientMgr.List() {
@@ -850,6 +1026,17 @@ func HandleGetUserNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res
return []hotline.Transaction{cc.NewReply(t, fields...)}
}
+// HandleTranAgreed completes the login process after the client agrees to server terms.
+// This handler finalizes user authentication and notifies other clients of the new user.
+//
+// Fields used in the request:
+// * 102 User Name Optional - Desired display name
+// * 104 User Icon ID Optional - User icon identifier
+// * 113 Options Optional - User preference flags (refuse PM, refuse chat, auto-reply)
+// * 215 Automatic Response Optional - Auto-reply message text
+//
+// Fields used in the reply:
+// None
func HandleTranAgreed(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if t.GetField(hotline.FieldUserName).Data != nil {
if cc.Authorize(hotline.AccessAnyName) {
@@ -859,6 +1046,27 @@ func HandleTranAgreed(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
}
}
+ if cc.Server.Redis != nil {
+ login := cc.Account.Login
+ ip, _, _ := net.SplitHostPort(cc.RemoteAddr)
+ // Remove old entry (login::ip)
+ cc.Server.Redis.SRem(context.Background(), "mobius:online", login+"::"+ip)
+ // Add new entry with login, nickname, ip
+ cc.Server.Redis.SAdd(context.Background(), "mobius:online", login+":"+string(cc.UserName)+":"+ip)
+ // Ban check for nickname
+ bannedNick, _ := cc.Server.Redis.SIsMember(context.Background(), "mobius:banned:nicknames", string(cc.UserName)).Result()
+ if bannedNick {
+ // Remove all possible online entries for this login and IP
+ cc.Server.Redis.SRem(context.Background(), "mobius:online", login+"::"+ip)
+ cc.Server.Redis.SRem(context.Background(), "mobius:online", login+":"+string(cc.UserName)+":"+ip)
+ // If we track the previous nickname, remove that too:
+ // cc.Server.Redis.SRem(context.Background(), "mobius:online", login+":"+oldNickname+":"+ip)
+ cc.Server.Redis.SAdd(context.Background(), "mobius:banned:ips", ip)
+ cc.Disconnect()
+ return res
+ }
+ }
+
cc.Icon = t.GetField(hotline.FieldUserIconID).Data
cc.Logger = cc.Logger.With("Name", string(cc.UserName))
@@ -893,7 +1101,8 @@ func HandleTranAgreed(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
res = append(res, trans...)
if cc.Server.Config.BannerFile != "" {
- res = append(res, hotline.NewTransaction(hotline.TranServerBanner, cc.ID, hotline.NewField(hotline.FieldBannerType, []byte("JPEG"))))
+ bannerType := hotline.FileTypeFromFilename(cc.Server.Config.BannerFile).TypeCode
+ res = append(res, hotline.NewTransaction(hotline.TranServerBanner, cc.ID, hotline.NewField(hotline.FieldBannerType, []byte(bannerType))))
}
res = append(res, cc.NewReply(t))
@@ -906,7 +1115,7 @@ func HandleTranAgreed(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
// 101 Data
func HandleTranOldPostNews(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessNewsPostArt) {
- return cc.NewErrReply(t, "You are not allowed to post news.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedPostNews)
}
newsDateTemplate := hotline.NewsDateFormat
@@ -937,9 +1146,17 @@ func HandleTranOldPostNews(cc *hotline.ClientConn, t *hotline.Transaction) (res
return append(res, cc.NewReply(t))
}
+// HandleDisconnectUser disconnects a specified user, optionally with a ban.
+//
+// Fields used in the request:
+// * 103 User ID Required - ID of the user to disconnect
+// * 113 Options Optional - Ban options ([0,1]=temporary ban, [0,2]=permanent ban)
+//
+// Fields used in the reply:
+// None
func HandleDisconnectUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessDisconUser) {
- return cc.NewErrReply(t, "You are not allowed to disconnect users.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedDisconnectUsers)
}
clientID := [2]byte(t.GetField(hotline.FieldUserID).Data)
@@ -961,12 +1178,12 @@ func HandleDisconnectUser(cc *hotline.ClientConn, t *hotline.Transaction) (res [
res = append(res, hotline.NewTransaction(
hotline.TranServerMsg,
clientConn.ID,
- hotline.NewField(hotline.FieldData, []byte("You are temporarily banned on this server")),
+ hotline.NewField(hotline.FieldData, []byte(ErrMsgTemporaryBan)),
hotline.NewField(hotline.FieldChatOptions, []byte{0, 0}),
))
banUntil := time.Now().Add(hotline.BanDuration)
- ip := strings.Split(clientConn.RemoteAddr, ":")[0]
+ ip, _, _ := net.SplitHostPort(clientConn.RemoteAddr)
err := cc.Server.BanList.Add(ip, &banUntil)
if err != nil {
@@ -980,11 +1197,11 @@ func HandleDisconnectUser(cc *hotline.ClientConn, t *hotline.Transaction) (res [
res = append(res, hotline.NewTransaction(
hotline.TranServerMsg,
clientConn.ID,
- hotline.NewField(hotline.FieldData, []byte("You are permanently banned on this server")),
+ hotline.NewField(hotline.FieldData, []byte(ErrMsgPermanentBan)),
hotline.NewField(hotline.FieldChatOptions, []byte{0, 0}),
))
- ip := strings.Split(clientConn.RemoteAddr, ":")[0]
+ ip, _, _ := net.SplitHostPort(clientConn.RemoteAddr)
err := cc.Server.BanList.Add(ip, nil)
if err != nil {
@@ -1001,12 +1218,16 @@ func HandleDisconnectUser(cc *hotline.ClientConn, t *hotline.Transaction) (res [
return append(res, cc.NewReply(t))
}
-// HandleGetNewsCatNameList returns a list of news categories for a path
+// HandleGetNewsCatNameList returns a list of news categories for the specified path.
+//
// Fields used in the request:
-// 325 News path (Optional)
+// * 325 News Path Optional - Path to the news category (root if omitted)
+//
+// Fields used in the reply:
+// * 323 News Category List Data Repeated - Category information for each subcategory
func HandleGetNewsCatNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessNewsReadArt) {
- return cc.NewErrReply(t, "You are not allowed to read news.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedReadNews)
}
pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath()
@@ -1028,9 +1249,17 @@ func HandleGetNewsCatNameList(cc *hotline.ClientConn, t *hotline.Transaction) (r
return append(res, cc.NewReply(t, fields...))
}
+// HandleNewNewsCat creates a new news category.
+//
+// Fields used in the request:
+// * 322 News Category Name Required - Name of the new category
+// * 325 News Path Optional - Parent path for the new category
+//
+// Fields used in the reply:
+// None
func HandleNewNewsCat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessNewsCreateCat) {
- return cc.NewErrReply(t, "You are not allowed to create news categories.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedCreateNewsCategories)
}
name := string(t.GetField(hotline.FieldNewsCatName).Data)
@@ -1047,12 +1276,17 @@ func HandleNewNewsCat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
return []hotline.Transaction{cc.NewReply(t)}
}
+// HandleNewNewsFldr creates a new news folder (bundle).
+//
// Fields used in the request:
-// 322 News category Name
-// 325 News path
+// * 201 File Name Required - Name of the new news folder
+// * 325 News Path Optional - Parent path for the new folder
+//
+// Fields used in the reply:
+// None
func HandleNewNewsFldr(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessNewsCreateFldr) {
- return cc.NewErrReply(t, "You are not allowed to create news folders.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedCreateNewsfolders)
}
name := string(t.GetField(hotline.FieldFileName).Data)
@@ -1069,16 +1303,16 @@ func HandleNewNewsFldr(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho
return append(res, cc.NewReply(t))
}
-// HandleGetNewsArtData gets the list of article names at the specified news path.
-
+// HandleGetNewsArtNameList returns a list of article names at the specified news path.
+//
// Fields used in the request:
-// 325 News path Optional
-
+// * 325 News Path Optional - Path to the news category
+//
// Fields used in the reply:
-// 321 News article list data Optional
+// * 321 News Article List Data Optional - List of articles in the category
func HandleGetNewsArtNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessNewsReadArt) {
- return cc.NewErrReply(t, "You are not allowed to read news.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedReadNews)
}
pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath()
@@ -1086,7 +1320,10 @@ func HandleGetNewsArtNameList(cc *hotline.ClientConn, t *hotline.Transaction) (r
return res
}
- nald := cc.Server.ThreadedNewsMgr.ListArticles(pathStrs)
+ nald, err := cc.Server.ThreadedNewsMgr.ListArticles(pathStrs)
+ if err != nil {
+ return res
+ }
b, err := io.ReadAll(&nald)
if err != nil {
@@ -1096,27 +1333,26 @@ func HandleGetNewsArtNameList(cc *hotline.ClientConn, t *hotline.Transaction) (r
return append(res, cc.NewReply(t, hotline.NewField(hotline.FieldNewsArtListData, b)))
}
-// HandleGetNewsArtData requests information about the specific news article.
-// Fields used in the request:
+// HandleGetNewsArtData retrieves the content and metadata of a specific news article.
//
-// Request fields
-// 325 News path
-// 326 News article Type
-// 327 News article data flavor
+// Fields used in the request:
+// * 325 News Path Required - Path to the news category
+// * 326 News Article ID Required - ID of the article to retrieve
+// * 327 News Article Data Flavor Optional - Data format ("text/plain")
//
// Fields used in the reply:
-// 328 News article title
-// 329 News article poster
-// 330 News article date
-// 331 Previous article Type
-// 332 Next article Type
-// 335 Parent article Type
-// 336 First child article Type
-// 327 News article data flavor "Should be “text/plain”
-// 333 News article data Optional (if data flavor is “text/plain”)
+// * 328 News Article Title Article title
+// * 329 News Article Poster Author of the article
+// * 330 News Article Date Publication date
+// * 331 Previous Article ID ID of previous article in thread
+// * 332 Next Article ID ID of next article in thread
+// * 335 Parent Article ID ID of parent article
+// * 336 First Child Article ID ID of first reply article
+// * 327 News Article Data Flavor Data format ("text/plain")
+// * 333 News Article Data Optional - Article content (if flavor is "text/plain")
func HandleGetNewsArtData(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessNewsReadArt) {
- return cc.NewErrReply(t, "You are not allowed to read news.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedReadNews)
}
newsPath, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath()
@@ -1149,8 +1385,10 @@ func HandleGetNewsArtData(cc *hotline.ClientConn, t *hotline.Transaction) (res [
}
// HandleDelNewsItem deletes a threaded news folder or category.
+//
// Fields used in the request:
-// 325 News path
+// * 325 News Path Required - Path to the news item to delete
+//
// Fields used in the reply:
// None
func HandleDelNewsItem(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
@@ -1164,11 +1402,11 @@ func HandleDelNewsItem(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho
if item.Type == [2]byte{0, 3} {
if !cc.Authorize(hotline.AccessNewsDeleteCat) {
- return cc.NewErrReply(t, "You are not allowed to delete news categories.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedDeleteNewsCategories)
}
} else {
if !cc.Authorize(hotline.AccessNewsDeleteFldr) {
- return cc.NewErrReply(t, "You are not allowed to delete news folders.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedDeleteNewsFolders)
}
}
@@ -1181,13 +1419,17 @@ func HandleDelNewsItem(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho
}
// HandleDelNewsArt deletes a threaded news article.
-// Request Fields
-// 325 News path
-// 326 News article Type
-// 337 News article recursive delete - Delete child articles (1) or not (0)
+//
+// Fields used in the request:
+// * 325 News Path Required - Path to the news category
+// * 326 News Article ID Required - ID of the article to delete
+// * 337 News Article Recursive Delete Optional - Delete child articles (1) or not (0)
+//
+// Fields used in the reply:
+// None
func HandleDelNewsArt(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessNewsDeleteArt) {
- return cc.NewErrReply(t, "You are not allowed to delete news articles.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedDeleteNewsArticles)
}
@@ -1212,16 +1454,21 @@ func HandleDelNewsArt(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
return []hotline.Transaction{cc.NewReply(t)}
}
-// Request fields
-// 325 News path
-// 326 News article Type Type of the parent article?
-// 328 News article title
-// 334 News article flags
-// 327 News article data flavor Currently “text/plain”
-// 333 News article data
+// HandlePostNewsArt creates a new threaded news article.
+//
+// Fields used in the request:
+// * 325 News Path Required - Path to the news category
+// * 326 News Article ID Optional - ID of parent article (0 for new thread)
+// * 328 News Article Title Required - Article title
+// * 334 News Article Flags Optional - Article flags
+// * 327 News Article Data Flavor Required - Data format ("text/plain")
+// * 333 News Article Data Required - Article content
+//
+// Fields used in the reply:
+// None
func HandlePostNewsArt(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessNewsPostArt) {
- return cc.NewErrReply(t, "You are not allowed to post news articles.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedPostNewsArticles)
}
pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath()
@@ -1253,10 +1500,16 @@ func HandlePostNewsArt(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho
return append(res, cc.NewReply(t))
}
-// HandleGetMsgs returns the flat news data
+// HandleGetMsgs returns the flat news data (message board content).
+//
+// Fields used in the request:
+// None
+//
+// Fields used in the reply:
+// * 101 Data Complete message board content
func HandleGetMsgs(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessNewsReadArt) {
- return cc.NewErrReply(t, "You are not allowed to read news.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedReadNews)
}
_, _ = cc.Server.MessageBoard.Seek(0, 0)
@@ -1269,9 +1522,22 @@ func HandleGetMsgs(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotlin
return append(res, cc.NewReply(t, hotline.NewField(hotline.FieldData, newsData)))
}
+// HandleDownloadFile initiates a file download transfer.
+//
+// Fields used in the request:
+// * 201 File Name Required - Name of the file to download
+// * 202 File Path Optional - Path to the file
+// * 203 File Resume Data Optional - Resume information for partial downloads
+// * 204 File Transfer Options Optional - Set to 2 for file preview
+//
+// Fields used in the reply:
+// * 107 Ref Num Transfer reference number
+// * 116 Waiting Count Number of users ahead in download queue
+// * 108 Transfer Size Total bytes to transfer
+// * 207 File Size Actual file size
func HandleDownloadFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessDownloadFile) {
- return cc.NewErrReply(t, "You are not allowed to download files.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedDownloadFiles)
}
fileName := t.GetField(hotline.FieldFileName).Data
@@ -1293,7 +1559,7 @@ func HandleDownloadFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []h
return res
}
- hlFile, err := hotline.NewFileWrapper(cc.Server.FS, fullFilePath, dataOffset)
+ hlFile, err := hotline.NewFile(cc.Server.FS, fullFilePath, dataOffset)
if err != nil {
return res
}
@@ -1335,9 +1601,20 @@ func HandleDownloadFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []h
}
// Download all files from the specified folder and sub-folders
+// HandleDownloadFolder initiates a folder download transfer (all files and subfolders).
+//
+// Fields used in the request:
+// * 201 File Name Required - Name of the folder to download
+// * 202 File Path Optional - Path to the folder
+//
+// Fields used in the reply:
+// * 107 Ref Num Transfer reference number
+// * 108 Transfer Size Total bytes to transfer
+// * 220 Folder Item Count Number of items in the folder
+// * 116 Waiting Count Number of users ahead in download queue
func HandleDownloadFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessDownloadFolder) {
- return cc.NewErrReply(t, "You are not allowed to download folders.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedDownloadFolders)
}
fullFilePath, err := hotline.ReadPath(cc.FileRoot(), t.GetField(hotline.FieldFilePath).Data, t.GetField(hotline.FieldFileName).Data)
@@ -1378,9 +1655,20 @@ func HandleDownloadFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res [
// 108 hotline.Transfer size Total size of all items in the folder
// 220 Folder item count
// 204 File transfer options "Optional Currently set to 1" (TODO: ??)
+// HandleUploadFolder initiates a folder upload transfer.
+//
+// Fields used in the request:
+// * 201 File Name Required - Name of the folder to upload
+// * 202 File Path Optional - Destination path on server
+// * 108 Transfer Size Required - Total size of all items in the folder
+// * 220 Folder Item Count Required - Number of items in the folder
+// * 204 File Transfer Options Optional - Currently set to 1
+//
+// Fields used in the reply:
+// * 107 Ref Num Transfer reference number
func HandleUploadFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessUploadFolder) {
- return cc.NewErrReply(t, "You are not allowed to upload folders.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedUploadFolders)
}
var fp hotline.FilePath
@@ -1393,7 +1681,7 @@ func HandleUploadFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []h
// Handle special cases for Upload and Drop Box folders
if !cc.Authorize(hotline.AccessUploadAnywhere) {
if !fp.IsUploadDir() && !fp.IsDropbox() && !fp.IsUserDir() {
- return cc.NewErrReply(t, fmt.Sprintf("Cannot accept upload of the folder \"%v\" because you are only allowed to upload to the \"Uploads\" folder.", string(t.GetField(hotline.FieldFileName).Data)))
+ return cc.NewErrReply(t, fmt.Sprintf(ErrMsgUploadRestrictedTemplate, "folder", string(t.GetField(hotline.FieldFileName).Data)))
}
}
@@ -1409,16 +1697,20 @@ func HandleUploadFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []h
return append(res, cc.NewReply(t, hotline.NewField(hotline.FieldRefNum, fileTransfer.RefNum[:])))
}
-// HandleUploadFile
+// HandleUploadFile initiates a file upload transfer.
+//
// Fields used in the request:
-// 201 File Name
-// 202 File path
-// 204 File transfer options "Optional
-// Used only to resume download, currently has value 2"
-// 108 File transfer size "Optional used if download is not resumed"
+// * 201 File Name Required - Name of the file to upload
+// * 202 File Path Optional - Destination path on server
+// * 204 File Transfer Options Optional - Set to 2 for resume upload
+// * 108 Transfer Size Optional - File size (not sent for resume)
+//
+// Fields used in the reply:
+// * 107 Ref Num Transfer reference number
+// * 203 File Resume Data Optional - Resume information (for resumed uploads)
func HandleUploadFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessUploadFile) {
- return cc.NewErrReply(t, "You are not allowed to upload files.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedUploadFiles)
}
fileName := t.GetField(hotline.FieldFileName).Data
@@ -1436,7 +1728,7 @@ func HandleUploadFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
// Handle special cases for Upload and Drop Box folders
if !cc.Authorize(hotline.AccessUploadAnywhere) {
if !fp.IsUploadDir() && !fp.IsDropbox() && !fp.IsUserDir() {
- return cc.NewErrReply(t, fmt.Sprintf("Cannot accept upload of the file \"%v\" because you are only allowed to upload to the \"Uploads\" folder.", string(fileName)))
+ return cc.NewErrReply(t, fmt.Sprintf(ErrMsgUploadRestrictedTemplate, "file", string(fileName)))
}
}
fullFilePath, err := hotline.ReadPath(cc.FileRoot(), filePath, fileName)
@@ -1446,7 +1738,7 @@ func HandleUploadFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
if _, err := cc.Server.FS.Stat(fullFilePath); err == nil {
if !fp.IsUserDir() {
- return cc.NewErrReply(t, fmt.Sprintf("Cannot accept upload because there is already a file named \"%v\". Try choosing a different Name.", string(fileName)))
+ return cc.NewErrReply(t, fmt.Sprintf(ErrMsgFileUploadConflictTemplate, string(fileName)))
}
}
@@ -1479,6 +1771,16 @@ func HandleUploadFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
return res
}
+// HandleSetClientUserInfo updates the current client's user information and preferences.
+//
+// Fields used in the request:
+// * 104 User Icon ID Optional - New user icon
+// * 102 User Name Optional - New display name (requires appropriate access)
+// * 113 Options Optional - User preference flags (refuse PM, refuse chat, auto-reply)
+// * 215 Automatic Response Optional - Auto-reply message text
+//
+// Fields used in the reply:
+// None
func HandleSetClientUserInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if len(t.GetField(hotline.FieldUserIconID).Data) == 4 {
cc.Icon = t.GetField(hotline.FieldUserIconID).Data[2:]
@@ -1486,7 +1788,33 @@ func HandleSetClientUserInfo(cc *hotline.ClientConn, t *hotline.Transaction) (re
cc.Icon = t.GetField(hotline.FieldUserIconID).Data
}
if cc.Authorize(hotline.AccessAnyName) {
+ oldNickname := string(cc.UserName)
+ newNickname := string(t.GetField(hotline.FieldUserName).Data)
cc.UserName = t.GetField(hotline.FieldUserName).Data
+ if cc.Server.Redis != nil {
+ login := cc.Account.Login
+ ip, _, _ := net.SplitHostPort(cc.RemoteAddr)
+ // Remove old entry (login:oldnickname:ip) and (login::ip)
+ cc.Server.Redis.SRem(context.Background(), "mobius:online", login+"::"+ip)
+ if oldNickname != "" {
+ cc.Server.Redis.SRem(context.Background(), "mobius:online", login+":"+oldNickname+":"+ip)
+ }
+ // Add new entry
+ cc.Server.Redis.SAdd(context.Background(), "mobius:online", login+":"+newNickname+":"+ip)
+ // Ban check for nickname
+ bannedNick, _ := cc.Server.Redis.SIsMember(context.Background(), "mobius:banned:nicknames", newNickname).Result()
+ if bannedNick {
+ // Remove all possible online entries for this login and IP
+ cc.Server.Redis.SRem(context.Background(), "mobius:online", login+"::"+ip)
+ cc.Server.Redis.SRem(context.Background(), "mobius:online", login+":"+newNickname+":"+ip)
+ if oldNickname != "" {
+ cc.Server.Redis.SRem(context.Background(), "mobius:online", login+":"+oldNickname+":"+ip)
+ }
+ cc.Server.Redis.SAdd(context.Background(), "mobius:banned:ips", ip)
+ cc.Disconnect()
+ return res
+ }
+ }
}
// the options field is only passed by the client versions > 1.2.3.
@@ -1519,15 +1847,27 @@ func HandleSetClientUserInfo(cc *hotline.ClientConn, t *hotline.Transaction) (re
return res
}
-// HandleKeepAlive responds to keepalive transactions with an empty reply
-// * HL 1.9.2 Client sends keepalive msg every 3 minutes
-// * HL 1.2.3 Client doesn't send keepalives
+// HandleKeepAlive responds to client keepalive messages to maintain the connection.
+// HL 1.9.2 clients send keepalive messages every 3 minutes, while HL 1.2.3 clients do not.
+//
+// Fields used in the request:
+// None
+//
+// Fields used in the reply:
+// None
func HandleKeepAlive(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
res = append(res, cc.NewReply(t))
return res
}
+// HandleGetFileNameList returns a list of files and folders in the specified directory.
+//
+// Fields used in the request:
+// * 202 File Path Optional - Path to list (root if omitted)
+//
+// Fields used in the reply:
+// * 200 File Name With Info Repeated - File information for each item
func HandleGetFileNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
fullPath, err := hotline.ReadPath(
cc.FileRoot(),
@@ -1547,7 +1887,7 @@ func HandleGetFileNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res
// Handle special case for drop box folders
if fp.IsDropbox() && !cc.Authorize(hotline.AccessViewDropBoxes) {
- return cc.NewErrReply(t, "You are not allowed to view drop boxes.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedViewDropBoxes)
}
fileNames, err := hotline.GetFileNameList(fullPath, cc.Server.Config.IgnoreFiles)
@@ -1572,10 +1912,20 @@ func HandleGetFileNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res
// If Accepted is clicked:
// 1. ClientB sends TranJoinChat with FieldChatID
-// HandleInviteNewChat invites users to new private chat
+// HandleInviteNewChat creates a new private chat and invites a user to join.
+//
+// Fields used in the request:
+// * 103 User ID Required - ID of the user to invite
+//
+// Fields used in the reply:
+// * 114 Chat ID New chat room identifier
+// * 102 User Name Inviting user's name
+// * 103 User ID Inviting user's ID
+// * 104 User Icon ID Inviting user's icon
+// * 112 User Flags Inviting user's flags
func HandleInviteNewChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessOpenChat) {
- return cc.NewErrReply(t, "You are not allowed to request private chat.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedRequestPrivateChat)
}
// Client to Invite
@@ -1592,7 +1942,7 @@ func HandleInviteNewChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []
hotline.NewTransaction(
hotline.TranServerMsg,
cc.ID,
- hotline.NewField(hotline.FieldData, []byte(string(targetClient.UserName)+" does not accept private chats.")),
+ hotline.NewField(hotline.FieldData, []byte(fmt.Sprintf(ErrMsgDoesNotAcceptTemplate, string(targetClient.UserName), "private chats"))),
hotline.NewField(hotline.FieldUserName, targetClient.UserName),
hotline.NewField(hotline.FieldUserID, targetClient.ID[:]),
hotline.NewField(hotline.FieldOptions, []byte{0, 2}),
@@ -1622,9 +1972,21 @@ func HandleInviteNewChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []
)
}
+// HandleInviteToChat invites a user to an existing private chat.
+//
+// Fields used in the request:
+// * 103 User ID Required - ID of the user to invite
+// * 114 Chat ID Required - Existing chat room identifier
+//
+// Fields used in the reply:
+// * 114 Chat ID Chat room identifier
+// * 102 User Name Inviting user's name
+// * 103 User ID Inviting user's ID
+// * 104 User Icon ID Inviting user's icon
+// * 112 User Flags Inviting user's flags
func HandleInviteToChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessOpenChat) {
- return cc.NewErrReply(t, "You are not allowed to request private chat.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedRequestPrivateChat)
}
// Client to Invite
@@ -1650,6 +2012,13 @@ func HandleInviteToChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []h
}
}
+// HandleRejectChatInvite processes a user's rejection of a private chat invitation.
+//
+// Fields used in the request:
+// * 114 Chat ID Required - Chat room identifier of the rejected invitation
+//
+// Fields used in the reply:
+// None
func HandleRejectChatInvite(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
chatID := [4]byte(t.GetField(hotline.FieldChatID).Data)
@@ -1667,11 +2036,14 @@ func HandleRejectChatInvite(cc *hotline.ClientConn, t *hotline.Transaction) (res
return res
}
-// HandleJoinChat is sent from a v1.8+ Hotline client when the joins a private chat
+// HandleJoinChat processes a user joining a private chat room.
+//
+// Fields used in the request:
+// * 114 Chat ID Required - Chat room identifier to join
+//
// Fields used in the reply:
-// * 115 Chat subject
-// * 300 User Name with info (Optional)
-// * 300 (more user names with info)
+// * 115 Chat Subject Current chat room subject
+// * 300 Username With Info Repeated - Information for each user in the chat
func HandleJoinChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
chatID := t.GetField(hotline.FieldChatID).Data
@@ -1711,11 +2083,13 @@ func HandleJoinChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotli
return append(res, cc.NewReply(t, replyFields...))
}
-// HandleLeaveChat is sent from a v1.8+ Hotline client when the user exits a private chat
+// HandleLeaveChat processes a user leaving a private chat room.
+//
// Fields used in the request:
-// - 114 FieldChatID
+// * 114 Chat ID Required - Chat room identifier to leave
//
-// Reply is not expected.
+// Fields used in the reply:
+// None
func HandleLeaveChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
chatID := t.GetField(hotline.FieldChatID).Data
@@ -1736,11 +2110,14 @@ func HandleLeaveChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotl
return res
}
-// HandleSetChatSubject is sent from a v1.8+ Hotline client when the user sets a private chat subject
+// HandleSetChatSubject sets the subject/topic for a private chat room.
+//
// Fields used in the request:
-// * 114 Chat Type
-// * 115 Chat subject
-// Reply is not expected.
+// * 114 Chat ID Required - Chat room identifier
+// * 115 Chat Subject Required - New chat room subject
+//
+// Fields used in the reply:
+// None
func HandleSetChatSubject(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
chatID := t.GetField(hotline.FieldChatID).Data
@@ -1761,17 +2138,18 @@ func HandleSetChatSubject(cc *hotline.ClientConn, t *hotline.Transaction) (res [
return res
}
-// HandleMakeAlias makes a file alias using the specified path.
+// HandleMakeAlias creates a symbolic link (alias) to a file or folder.
+//
// Fields used in the request:
-// 201 File Name
-// 202 File path
-// 212 File new path Destination path
+// * 201 File Name Required - Name of the file to create an alias of
+// * 202 File Path Required - Path to the source file
+// * 212 File New Path Required - Destination path for the alias
//
// Fields used in the reply:
// None
func HandleMakeAlias(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
if !cc.Authorize(hotline.AccessMakeAlias) {
- return cc.NewErrReply(t, "You are not allowed to make aliases.")
+ return cc.NewErrReply(t, ErrMsgNotAllowedMakeAliases)
}
fileName := t.GetField(hotline.FieldFileName).Data
filePath := t.GetField(hotline.FieldFilePath).Data
@@ -1788,19 +2166,21 @@ func HandleMakeAlias(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotl
}
if err := cc.Server.FS.Symlink(fullFilePath, fullNewFilePath); err != nil {
- return cc.NewErrReply(t, "Error creating alias")
+ return cc.NewErrReply(t, ErrMsgCreateAlias)
}
res = append(res, cc.NewReply(t))
return res
}
-// HandleDownloadBanner handles requests for a new banner from the server
+// HandleDownloadBanner initiates a download of the server banner image.
+//
// Fields used in the request:
// None
+//
// Fields used in the reply:
-// 107 FieldRefNum Used later for transfer
-// 108 FieldTransferSize Size of data to be downloaded
+// * 107 Ref Num Transfer reference number
+// * 108 Transfer Size Size of banner data to download
func HandleDownloadBanner(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
ft := cc.NewFileTransfer(hotline.BannerDownload, "", []byte{}, []byte{}, make([]byte, 4))
binary.BigEndian.PutUint32(ft.TransferSize, uint32(len(cc.Server.Banner)))
diff --git a/internal/mobius/transaction_handlers_test.go b/internal/mobius/transaction_handlers_test.go
index 43c033f..d943649 100644
--- a/internal/mobius/transaction_handlers_test.go
+++ b/internal/mobius/transaction_handlers_test.go
@@ -1917,11 +1917,11 @@ func TestHandleDownloadFile(t *testing.T) {
ForkCount: [2]byte{0, 2},
ForkInfoList: []hotline.ForkInfoList{
{
- Fork: [4]byte{0x44, 0x41, 0x54, 0x41}, // "DATA"
- DataSize: [4]byte{0, 0, 0x01, 0x00}, // request offset 256
+ Fork: hotline.ForkTypeDATA,
+ DataSize: [4]byte{0, 0, 0x01, 0x00}, // request offset 256
},
{
- Fork: [4]byte{0x4d, 0x41, 0x43, 0x52}, // "MACR"
+ Fork: hotline.ForkTypeMACR,
DataSize: [4]byte{0, 0, 0, 0},
},
},
@@ -2873,6 +2873,56 @@ func TestHandleTranAgreed(t *testing.T) {
},
},
},
+ {
+ name: "with gif banner",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessDisconUser)
+ bits.Set(hotline.AccessAnyName)
+ return bits
+ }()},
+ Icon: []byte{0, 1},
+ Flags: [2]byte{0, 1},
+ Version: []byte{0, 1},
+ ID: [2]byte{0, 1},
+ Logger: NewTestLogger(),
+ Server: &hotline.Server{
+ Config: hotline.Config{
+ BannerFile: "Banner.gif",
+ },
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("List").Return([]*hotline.ClientConn{},
+ )
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranAgreed, [2]byte{},
+ hotline.NewField(hotline.FieldUserName, []byte("username")),
+ hotline.NewField(hotline.FieldUserIconID, []byte{0, 1}),
+ hotline.NewField(hotline.FieldOptions, []byte{0, 0}),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ Type: [2]byte{0, 0x7a},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldBannerType, []byte("GIFf")),
+ },
+ },
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ Fields: []hotline.Field{},
+ },
+ },
+ },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -2935,6 +2985,220 @@ func TestHandleSetClientUserInfo(t *testing.T) {
},
},
},
+ {
+ name: "when client has AccessAnyName and changes username",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessAnyName)
+ return bits
+ }(),
+ },
+ ID: [2]byte{0, 1},
+ UserName: []byte("Guest"),
+ Flags: [2]byte{0, 1},
+ Server: &hotline.Server{
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("List").Return([]*hotline.ClientConn{
+ {
+ ID: [2]byte{0, 1},
+ },
+ })
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranSetClientUserInfo, [2]byte{},
+ hotline.NewField(hotline.FieldUserName, []byte("NewName")),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ Type: [2]byte{0x01, 0x2d},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+ hotline.NewField(hotline.FieldUserIconID, []byte{}),
+ hotline.NewField(hotline.FieldUserFlags, []byte{0, 1}),
+ hotline.NewField(hotline.FieldUserName, []byte("NewName"))},
+ },
+ },
+ },
+ {
+ name: "when client updates icon with 4-byte data",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ ID: [2]byte{0, 1},
+ UserName: []byte("Guest"),
+ Flags: [2]byte{0, 1},
+ Server: &hotline.Server{
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("List").Return([]*hotline.ClientConn{
+ {
+ ID: [2]byte{0, 1},
+ },
+ })
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranSetClientUserInfo, [2]byte{},
+ hotline.NewField(hotline.FieldUserIconID, []byte{0, 1, 0, 5}),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ Type: [2]byte{0x01, 0x2d},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+ hotline.NewField(hotline.FieldUserIconID, []byte{0, 5}),
+ hotline.NewField(hotline.FieldUserFlags, []byte{0, 1}),
+ hotline.NewField(hotline.FieldUserName, []byte("Guest"))},
+ },
+ },
+ },
+ {
+ name: "when client updates icon with 2-byte data",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ ID: [2]byte{0, 1},
+ UserName: []byte("Guest"),
+ Flags: [2]byte{0, 1},
+ Server: &hotline.Server{
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("List").Return([]*hotline.ClientConn{
+ {
+ ID: [2]byte{0, 1},
+ },
+ })
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranSetClientUserInfo, [2]byte{},
+ hotline.NewField(hotline.FieldUserIconID, []byte{0, 3}),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ Type: [2]byte{0x01, 0x2d},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+ hotline.NewField(hotline.FieldUserIconID, []byte{0, 3}),
+ hotline.NewField(hotline.FieldUserFlags, []byte{0, 1}),
+ hotline.NewField(hotline.FieldUserName, []byte("Guest"))},
+ },
+ },
+ },
+ {
+ name: "when client sets user options with auto-reply",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ ID: [2]byte{0, 1},
+ UserName: []byte("Guest"),
+ Flags: [2]byte{0, 1},
+ Version: []byte{0x01, 0x03},
+ Server: &hotline.Server{
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("List").Return([]*hotline.ClientConn{
+ {
+ ID: [2]byte{0, 1},
+ },
+ })
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranSetClientUserInfo, [2]byte{},
+ hotline.NewField(hotline.FieldOptions, []byte{0, 4}),
+ hotline.NewField(hotline.FieldAutomaticResponse, []byte("Away message")),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ Type: [2]byte{0x01, 0x2d},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+ hotline.NewField(hotline.FieldUserIconID, []byte{}),
+ hotline.NewField(hotline.FieldUserFlags, []byte{0, 1}),
+ hotline.NewField(hotline.FieldUserName, []byte("Guest"))},
+ },
+ },
+ },
+ {
+ name: "when client sets refuse private messages flag",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ ID: [2]byte{0, 1},
+ UserName: []byte("Guest"),
+ Flags: [2]byte{0, 1},
+ Version: []byte{0x01, 0x03},
+ Server: &hotline.Server{
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("List").Return([]*hotline.ClientConn{
+ {
+ ID: [2]byte{0, 1},
+ },
+ })
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranSetClientUserInfo, [2]byte{},
+ hotline.NewField(hotline.FieldOptions, []byte{0, 2}),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ Type: [2]byte{0x01, 0x2d},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+ hotline.NewField(hotline.FieldUserIconID, []byte{}),
+ hotline.NewField(hotline.FieldUserFlags, []byte{0, 9}),
+ hotline.NewField(hotline.FieldUserName, []byte("Guest"))},
+ },
+ },
+ },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -3868,3 +4132,482 @@ func TestHandleDownloadFolder(t *testing.T) {
})
}
}
+
+func TestHandleJoinChat(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ want []hotline.Transaction
+ }{
+ {
+ name: "joins private chat and notifies existing members",
+ args: args{
+ cc: &hotline.ClientConn{
+ UserName: []byte("NewUser"),
+ ID: [2]byte{0, 3},
+ Icon: []byte{0, 5},
+ Flags: [2]byte{0, 1},
+ Account: &hotline.Account{
+ Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ },
+ Server: &hotline.Server{
+ ChatMgr: func() *hotline.MockChatManager {
+ m := hotline.MockChatManager{}
+ // Mock existing members before join
+ m.On("Members", hotline.ChatID{0, 0, 0, 1}).Return([]*hotline.ClientConn{
+ {
+ UserName: []byte("User1"),
+ ID: [2]byte{0, 1},
+ Icon: []byte{0, 2},
+ Flags: [2]byte{0, 0},
+ },
+ {
+ UserName: []byte("User2"),
+ ID: [2]byte{0, 2},
+ Icon: []byte{0, 3},
+ Flags: [2]byte{0, 0},
+ },
+ })
+ // Mock join operation
+ m.On("Join", hotline.ChatID{0, 0, 0, 1}, mock.AnythingOfType("*hotline.ClientConn")).Return()
+ // Mock members after join (including new user)
+ m.On("Members", hotline.ChatID{0, 0, 0, 1}).Return([]*hotline.ClientConn{
+ {
+ UserName: []byte("User1"),
+ ID: [2]byte{0, 1},
+ Icon: []byte{0, 2},
+ Flags: [2]byte{0, 0},
+ },
+ {
+ UserName: []byte("User2"),
+ ID: [2]byte{0, 2},
+ Icon: []byte{0, 3},
+ Flags: [2]byte{0, 0},
+ },
+ {
+ UserName: []byte("NewUser"),
+ ID: [2]byte{0, 3},
+ Icon: []byte{0, 5},
+ Flags: [2]byte{0, 1},
+ },
+ })
+ // Mock chat subject
+ m.On("GetSubject", hotline.ChatID{0, 0, 0, 1}).Return("Test Chat Room")
+ return &m
+ }(),
+ },
+ },
+ t: hotline.Transaction{
+ Type: [2]byte{0, 0x74}, // TranJoinChat
+ ID: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}),
+ },
+ },
+ },
+ want: []hotline.Transaction{
+ // Notification to existing member 1
+ {
+ ClientID: [2]byte{0, 1},
+ Type: [2]byte{0, 0x75}, // TranNotifyChatChangeUser
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldUserName, []byte("NewUser")),
+ hotline.NewField(hotline.FieldUserID, []byte{0, 3}),
+ hotline.NewField(hotline.FieldUserIconID, []byte{0, 5}),
+ hotline.NewField(hotline.FieldUserFlags, []byte{0, 1}),
+ },
+ },
+ // Notification to existing member 2
+ {
+ ClientID: [2]byte{0, 2},
+ Type: [2]byte{0, 0x75}, // TranNotifyChatChangeUser
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldUserName, []byte("NewUser")),
+ hotline.NewField(hotline.FieldUserID, []byte{0, 3}),
+ hotline.NewField(hotline.FieldUserIconID, []byte{0, 5}),
+ hotline.NewField(hotline.FieldUserFlags, []byte{0, 1}),
+ },
+ },
+ // Reply to joining user
+ {
+ ClientID: [2]byte{0, 3},
+ IsReply: 0x01,
+ Type: [2]byte{0, 0}, // Reply type is [0, 0]
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldChatSubject, []byte("Test Chat Room")),
+ // User1 info
+ hotline.NewField(hotline.FieldUsernameWithInfo, []byte{
+ 0x00, 0x01, // User ID
+ 0x00, 0x02, // Icon ID
+ 0x00, 0x00, // Flags
+ 0x00, 0x05, // Username length
+ 0x55, 0x73, 0x65, 0x72, 0x31, // "User1"
+ }),
+ // User2 info
+ hotline.NewField(hotline.FieldUsernameWithInfo, []byte{
+ 0x00, 0x02, // User ID
+ 0x00, 0x03, // Icon ID
+ 0x00, 0x00, // Flags
+ 0x00, 0x05, // Username length
+ 0x55, 0x73, 0x65, 0x72, 0x32, // "User2"
+ }),
+ },
+ },
+ },
+ },
+ {
+ name: "joins empty private chat",
+ args: args{
+ cc: &hotline.ClientConn{
+ UserName: []byte("OnlyUser"),
+ ID: [2]byte{0, 1},
+ Icon: []byte{0, 1},
+ Flags: [2]byte{0, 0},
+ Account: &hotline.Account{
+ Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ },
+ Server: &hotline.Server{
+ ChatMgr: func() *hotline.MockChatManager {
+ m := hotline.MockChatManager{}
+ // Mock empty chat before join
+ m.On("Members", hotline.ChatID{0, 0, 0, 2}).Return([]*hotline.ClientConn{})
+ // Mock join operation
+ m.On("Join", hotline.ChatID{0, 0, 0, 2}, mock.AnythingOfType("*hotline.ClientConn")).Return()
+ // Mock members after join
+ m.On("Members", hotline.ChatID{0, 0, 0, 2}).Return([]*hotline.ClientConn{
+ {
+ UserName: []byte("OnlyUser"),
+ ID: [2]byte{0, 1},
+ Icon: []byte{0, 1},
+ Flags: [2]byte{0, 0},
+ },
+ })
+ // Mock chat subject
+ m.On("GetSubject", hotline.ChatID{0, 0, 0, 2}).Return("Empty Chat")
+ return &m
+ }(),
+ },
+ },
+ t: hotline.Transaction{
+ Type: [2]byte{0, 0x74}, // TranJoinChat
+ ID: [4]byte{0, 0, 0, 2},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 2}),
+ },
+ },
+ },
+ want: []hotline.Transaction{
+ // Only reply to joining user (no existing members to notify)
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ Type: [2]byte{0, 0}, // Reply type is [0, 0]
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldChatSubject, []byte("Empty Chat")),
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := HandleJoinChat(tt.args.cc, &tt.args.t)
+ if !TranAssertEqual(t, tt.want, got) {
+ t.Errorf("HandleJoinChat() got = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestHandleRejectChatInvite(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ want []hotline.Transaction
+ }{
+ {
+ name: "rejects invite and notifies chat members",
+ args: args{
+ cc: &hotline.ClientConn{
+ UserName: []byte("RejectUser"),
+ ID: [2]byte{0, 3},
+ Server: &hotline.Server{
+ ChatMgr: func() *hotline.MockChatManager {
+ m := hotline.MockChatManager{}
+ // Mock current members of the chat
+ m.On("Members", hotline.ChatID{0, 0, 0, 1}).Return([]*hotline.ClientConn{
+ {
+ UserName: []byte("Member1"),
+ ID: [2]byte{0, 1},
+ },
+ {
+ UserName: []byte("Member2"),
+ ID: [2]byte{0, 2},
+ },
+ })
+ return &m
+ }(),
+ },
+ },
+ t: hotline.Transaction{
+ Type: [2]byte{0, 0x72}, // TranRejectChatInvite
+ ID: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}),
+ },
+ },
+ },
+ want: []hotline.Transaction{
+ // Notification to member 1
+ {
+ ClientID: [2]byte{0, 1},
+ Type: [2]byte{0, 0x6A}, // TranChatMsg
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldData, []byte("RejectUser declined invitation to chat")),
+ },
+ },
+ // Notification to member 2
+ {
+ ClientID: [2]byte{0, 2},
+ Type: [2]byte{0, 0x6A}, // TranChatMsg
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldData, []byte("RejectUser declined invitation to chat")),
+ },
+ },
+ },
+ },
+ {
+ name: "rejects invite to empty chat",
+ args: args{
+ cc: &hotline.ClientConn{
+ UserName: []byte("LoneRejecter"),
+ ID: [2]byte{0, 1},
+ Server: &hotline.Server{
+ ChatMgr: func() *hotline.MockChatManager {
+ m := hotline.MockChatManager{}
+ // Mock empty chat (no members)
+ m.On("Members", hotline.ChatID{0, 0, 0, 2}).Return([]*hotline.ClientConn{})
+ return &m
+ }(),
+ },
+ },
+ t: hotline.Transaction{
+ Type: [2]byte{0, 0x72}, // TranRejectChatInvite
+ ID: [4]byte{0, 0, 0, 2},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 2}),
+ },
+ },
+ },
+ want: []hotline.Transaction{
+ // No notifications (no members to notify)
+ },
+ },
+ {
+ name: "rejects invite with single member",
+ args: args{
+ cc: &hotline.ClientConn{
+ UserName: []byte("Shy"),
+ ID: [2]byte{0, 2},
+ Server: &hotline.Server{
+ ChatMgr: func() *hotline.MockChatManager {
+ m := hotline.MockChatManager{}
+ // Mock chat with single member
+ m.On("Members", hotline.ChatID{0, 0, 0, 3}).Return([]*hotline.ClientConn{
+ {
+ UserName: []byte("OnlyMember"),
+ ID: [2]byte{0, 1},
+ },
+ })
+ return &m
+ }(),
+ },
+ },
+ t: hotline.Transaction{
+ Type: [2]byte{0, 0x72}, // TranRejectChatInvite
+ ID: [4]byte{0, 0, 0, 3},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 3}),
+ },
+ },
+ },
+ want: []hotline.Transaction{
+ // Notification to the single member
+ {
+ ClientID: [2]byte{0, 1},
+ Type: [2]byte{0, 0x6A}, // TranChatMsg
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldData, []byte("Shy declined invitation to chat")),
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := HandleRejectChatInvite(tt.args.cc, &tt.args.t)
+ if !TranAssertEqual(t, tt.want, got) {
+ t.Errorf("HandleRejectChatInvite() got = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestHandleInviteToChat(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ want []hotline.Transaction
+ }{
+ {
+ name: "invites user to chat successfully",
+ args: args{
+ cc: &hotline.ClientConn{
+ UserName: []byte("Inviter"),
+ ID: [2]byte{0, 1},
+ Icon: []byte{0, 2},
+ Flags: [2]byte{0, 3},
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ access := hotline.AccessBitmap{}
+ access.Set(hotline.AccessOpenChat)
+ return access
+ }(),
+ },
+ },
+ t: hotline.Transaction{
+ Type: [2]byte{0, 0x71}, // TranInviteToChat
+ ID: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldUserID, []byte{0, 5}),
+ hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 10}),
+ },
+ },
+ },
+ want: []hotline.Transaction{
+ // Invite sent to target user
+ {
+ ClientID: [2]byte{0, 5},
+ Type: [2]byte{0, 0x71}, // TranInviteToChat
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldUserName, []byte("Inviter")),
+ hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+ },
+ },
+ // Reply to inviting user
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ Type: [2]byte{0, 0}, // Reply type is [0, 0]
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldUserName, []byte("Inviter")),
+ hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+ hotline.NewField(hotline.FieldUserIconID, []byte{0, 2}),
+ hotline.NewField(hotline.FieldUserFlags, []byte{0, 3}),
+ },
+ },
+ },
+ },
+ {
+ name: "returns error when user lacks permission",
+ args: args{
+ cc: &hotline.ClientConn{
+ UserName: []byte("NoPermUser"),
+ ID: [2]byte{0, 2},
+ Account: &hotline.Account{
+ Access: hotline.AccessBitmap{}, // No permissions
+ },
+ },
+ t: hotline.Transaction{
+ Type: [2]byte{0, 0x71}, // TranInviteToChat
+ ID: [4]byte{0, 0, 0, 2},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldUserID, []byte{0, 3}),
+ hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 5}),
+ },
+ },
+ },
+ want: []hotline.Transaction{
+ // Error reply to requesting user
+ {
+ ClientID: [2]byte{0, 2},
+ IsReply: 0x01,
+ Type: [2]byte{0, 0}, // Reply type is [0, 0]
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to request private chat.")),
+ },
+ },
+ },
+ },
+ {
+ name: "invites to different chat room",
+ args: args{
+ cc: &hotline.ClientConn{
+ UserName: []byte("Host"),
+ ID: [2]byte{0, 10},
+ Icon: []byte{0, 15},
+ Flags: [2]byte{0, 20},
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ access := hotline.AccessBitmap{}
+ access.Set(hotline.AccessOpenChat)
+ return access
+ }(),
+ },
+ },
+ t: hotline.Transaction{
+ Type: [2]byte{0, 0x71}, // TranInviteToChat
+ ID: [4]byte{0, 0, 0, 3},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldUserID, []byte{0, 99}),
+ hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 25}),
+ },
+ },
+ },
+ want: []hotline.Transaction{
+ // Invite sent to target user
+ {
+ ClientID: [2]byte{0, 99},
+ Type: [2]byte{0, 0x71}, // TranInviteToChat
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldUserName, []byte("Host")),
+ hotline.NewField(hotline.FieldUserID, []byte{0, 10}),
+ },
+ },
+ // Reply to inviting user
+ {
+ ClientID: [2]byte{0, 10},
+ IsReply: 0x01,
+ Type: [2]byte{0, 0}, // Reply type is [0, 0]
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldUserName, []byte("Host")),
+ hotline.NewField(hotline.FieldUserID, []byte{0, 10}),
+ hotline.NewField(hotline.FieldUserIconID, []byte{0, 15}),
+ hotline.NewField(hotline.FieldUserFlags, []byte{0, 20}),
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := HandleInviteToChat(tt.args.cc, &tt.args.t)
+ if !TranAssertEqual(t, tt.want, got) {
+ t.Errorf("HandleInviteToChat() got = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}