├── state ├── migrations │ ├── 0007_icq_fields.down.sql │ ├── 0004_chat_rooms.down.sql │ ├── 0006_chat_cookie.down.sql │ ├── 0008_offline_msgs.down.sql │ ├── 0015_bart_item_type.down.sql │ ├── 0011_toc_config.up.sql │ ├── 0013_is_bot.up.sql │ ├── 0015_bart_item_type.up.sql │ ├── 0006_chat_cookie.up.sql │ ├── 0012_suspended_status.up.sql │ ├── 0014_warn_state.down.sql │ ├── 0001_initialize_schema.down.sql │ ├── 0025_profile_mime_type_update_time.down.sql │ ├── 0017_web_preferences.down.sql │ ├── 0025_profile_mime_type_update_time.up.sql │ ├── 0014_warn_state.up.sql │ ├── 0023_web_api_keys.down.sql │ ├── 0008_offline_msgs.up.sql │ ├── 0016_webapi_tokens.down.sql │ ├── 0021_vanity_urls.down.sql │ ├── 0004_chat_rooms.up.sql │ ├── 0024_fix_warn_timestamp.down.sql │ ├── 0018_oscar_bridge_sessions.down.sql │ ├── 0005_user_settings.up.sql │ ├── 0020_buddy_feeds.down.sql │ ├── 0019_api_analytics.down.sql │ ├── 0024_fix_warn_timestamp.up.sql │ ├── 0022_web_chat_rooms.down.sql │ ├── 0003_formatted_screen_name.down.sql │ ├── 0002_hash_columns.down.sql │ ├── 0002_hash_columns.up.sql │ ├── 0005_user_settings.down.sql │ ├── 0016_webapi_tokens.up.sql │ ├── 0010_feedbag_pd.down.sql │ ├── 0026_offline_message_fk_index.down.sql │ ├── 0001_initialize_schema.up.sql │ ├── 0003_formatted_screen_name.up.sql │ ├── 0023_web_api_keys.up.sql │ ├── 0010_feedbag_pd.up.sql │ ├── 0026_offline_message_fk_index.up.sql │ ├── 0017_web_preferences.up.sql │ ├── 0018_oscar_bridge_sessions.up.sql │ ├── 0021_vanity_urls.up.sql │ ├── 0020_buddy_feeds.up.sql │ ├── 0019_api_analytics.up.sql │ └── 0022_web_chat_rooms.up.sql ├── chat_test.go ├── cookie.go ├── webapi_auth.go └── chat.go ├── .gitignore ├── codecov.yml ├── server ├── oscar │ ├── helpers_test.go │ ├── mock_chat_session_manager_test.go │ ├── mock_online_notifier_test.go │ ├── mock_response_writer_test.go │ ├── mock_stats_service_test.go │ ├── mock_rate_limit_updater_test.go │ ├── mock_user_lookup_service_test.go │ ├── middleware │ │ └── logger.go │ └── mock_chat_service_test.go ├── webapi │ ├── handlers │ │ └── expressions.go │ ├── handler.go │ └── oscar_config.go ├── http │ ├── mock_chat_room_deleter_test.go │ ├── mock_chat_session_retriever_test.go │ ├── mock_chat_room_creator_test.go │ ├── mock_message_relayer_test.go │ ├── mock_chat_room_retriever_test.go │ ├── mock_profile_retriever_test.go │ ├── mock_feedbag_retriever_test.go │ └── mock_session_retriever_test.go ├── toc │ ├── mock_dir_search_service_test.go │ ├── mock_admin_service_test.go │ ├── mock_chat_service_test.go │ └── mock_cookie_baker_test.go └── kerberos │ ├── kerberos.go │ └── mock_auth_test.go ├── Dockerfile ├── config ├── ssl │ ├── stunnel.conf │ └── settings.env ├── ras.service └── settings.env ├── Dockerfile.certgen ├── scripts ├── run_stunnel.sh └── run_dev.sh ├── wire ├── snacs_string_test.go ├── user.go └── snacs_test.go ├── foodgroup ├── stats_test.go ├── stats.go ├── user_lookup.go ├── mock_session_retriever_test.go ├── user_lookup_test.go └── mock_cookie_baker_test.go ├── cmd ├── snac_buster │ └── main.go ├── server │ └── main.go └── config_generator │ └── main.go ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── go.yml ├── LICENSE ├── go.mod ├── docker-compose.yaml ├── docs ├── SYSTEMD.md ├── RELEASE.md ├── BUILD.md ├── DOCKER.md ├── LINUX.md ├── CLIENT.md ├── RENDEZVOUS.md ├── WINDOWS.md ├── MACOS.md └── AIM_6_7.md ├── .goreleaser.yaml ├── Dockerfile.stunnel └── Makefile /state/migrations/0007_icq_fields.down.sql: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /state/migrations/0004_chat_rooms.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE chatRoom; -------------------------------------------------------------------------------- /state/migrations/0006_chat_cookie.down.sql: -------------------------------------------------------------------------------- 1 | -- nothing to do here -------------------------------------------------------------------------------- /state/migrations/0008_offline_msgs.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE offlineMessage; -------------------------------------------------------------------------------- /state/migrations/0015_bart_item_type.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE bartItem DROP COLUMN type; 2 | -------------------------------------------------------------------------------- /state/migrations/0011_toc_config.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users 2 | ADD COLUMN tocConfig TEXT NOT NULL DEFAULT ''; -------------------------------------------------------------------------------- /state/migrations/0013_is_bot.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users 2 | ADD COLUMN isBot BOOLEAN NOT NULL DEFAULT false; -------------------------------------------------------------------------------- /state/migrations/0015_bart_item_type.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE bartItem ADD COLUMN type INTEGER NOT NULL DEFAULT 0; 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | *.db 3 | *.sqlite 4 | dist/ 5 | *.DS_Store 6 | certs/ 7 | .vscode/ 8 | wapi-docs/ 9 | .cursor/ -------------------------------------------------------------------------------- /state/migrations/0006_chat_cookie.up.sql: -------------------------------------------------------------------------------- 1 | UPDATE chatRoom 2 | SET cookie = exchange || '-' || '0' || '-' || name; 3 | -------------------------------------------------------------------------------- /state/migrations/0012_suspended_status.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users 2 | ADD COLUMN suspendedStatus int NOT NULL DEFAULT 0; 3 | -------------------------------------------------------------------------------- /state/migrations/0014_warn_state.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users 2 | DROP COLUMN lastWarnLevel; 3 | 4 | ALTER TABLE users 5 | DROP COLUMN lastWarnUpdate; 6 | -------------------------------------------------------------------------------- /state/migrations/0001_initialize_schema.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS bartItem; 2 | DROP TABLE IF EXISTS profile; 3 | DROP TABLE IF EXISTS feedbag; 4 | DROP TABLE IF EXISTS user; -------------------------------------------------------------------------------- /state/migrations/0025_profile_mime_type_update_time.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE profile 2 | DROP COLUMN updateTime; 3 | 4 | ALTER TABLE profile 5 | DROP COLUMN mimeType; 6 | 7 | -------------------------------------------------------------------------------- /state/migrations/0017_web_preferences.down.sql: -------------------------------------------------------------------------------- 1 | -- Drop the web_preferences table and its index 2 | DROP INDEX IF EXISTS idx_web_preferences_screen_name; 3 | DROP TABLE IF EXISTS web_preferences; 4 | -------------------------------------------------------------------------------- /state/migrations/0025_profile_mime_type_update_time.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE profile 2 | ADD COLUMN mimeType TEXT; 3 | 4 | ALTER TABLE profile 5 | ADD COLUMN updateTime INTEGER NOT NULL DEFAULT 0; 6 | 7 | -------------------------------------------------------------------------------- /state/migrations/0014_warn_state.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users 2 | ADD COLUMN lastWarnUpdate DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00'; 3 | 4 | ALTER TABLE users 5 | ADD COLUMN lastWarnLevel INTEGER NOT NULL DEFAULT 0; 6 | -------------------------------------------------------------------------------- /state/migrations/0023_web_api_keys.down.sql: -------------------------------------------------------------------------------- 1 | -- Drop indexes 2 | DROP INDEX IF EXISTS idx_web_api_keys_is_active; 3 | DROP INDEX IF EXISTS idx_web_api_keys_dev_key; 4 | 5 | -- Drop table 6 | DROP TABLE IF EXISTS web_api_keys; 7 | 8 | -------------------------------------------------------------------------------- /state/migrations/0008_offline_msgs.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE offlineMessage 2 | ( 3 | sender VARCHAR(16) NOT NULL, 4 | recipient VARCHAR(16) NOT NULL, 5 | message BLOB NOT NULL, 6 | sent TIMESTAMP NOT NULL 7 | ); -------------------------------------------------------------------------------- /state/migrations/0016_webapi_tokens.down.sql: -------------------------------------------------------------------------------- 1 | -- Drop Web API tokens table and indices 2 | DROP INDEX IF EXISTS idx_webapi_tokens_screen_name; 3 | DROP INDEX IF EXISTS idx_webapi_tokens_expires_at; 4 | DROP TABLE IF EXISTS webapi_tokens; 5 | -------------------------------------------------------------------------------- /state/migrations/0021_vanity_urls.down.sql: -------------------------------------------------------------------------------- 1 | -- Rollback Migration: 0020_vanity_urls 2 | -- Description: Remove vanity URL tables 3 | -- Date: 2024-12-28 4 | 5 | DROP TABLE IF EXISTS vanity_url_redirects; 6 | DROP TABLE IF EXISTS vanity_urls; 7 | -------------------------------------------------------------------------------- /state/migrations/0004_chat_rooms.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE chatRoom 2 | ( 3 | cookie TEXT PRIMARY KEY, 4 | exchange INTEGER, 5 | name TEXT, 6 | created TIMESTAMP, 7 | creator TEXT, 8 | UNIQUE (exchange, name) 9 | ); 10 | -------------------------------------------------------------------------------- /state/migrations/0024_fix_warn_timestamp.down.sql: -------------------------------------------------------------------------------- 1 | -- Revert lastWarnUpdate back to DATETIME type 2 | 3 | ALTER TABLE users DROP COLUMN lastWarnUpdate; 4 | 5 | ALTER TABLE users ADD COLUMN lastWarnUpdate DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00'; 6 | 7 | -------------------------------------------------------------------------------- /state/migrations/0018_oscar_bridge_sessions.down.sql: -------------------------------------------------------------------------------- 1 | -- Drop the OSCAR bridge sessions table and its indexes 2 | 3 | DROP INDEX IF EXISTS idx_oscar_bridge_screen_name; 4 | DROP INDEX IF EXISTS idx_oscar_bridge_last_accessed; 5 | DROP TABLE IF EXISTS oscar_bridge_sessions; 6 | -------------------------------------------------------------------------------- /state/migrations/0005_user_settings.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users 2 | ADD COLUMN confirmStatus BOOL DEFAULT FALSE; 3 | ALTER TABLE users 4 | ADD COLUMN emailAddress VARCHAR(320) NOT NULL DEFAULT ''; 5 | ALTER TABLE users 6 | ADD COLUMN regStatus INT NOT NULL DEFAULT 3; -------------------------------------------------------------------------------- /state/migrations/0020_buddy_feeds.down.sql: -------------------------------------------------------------------------------- 1 | -- Rollback Migration: 0019_buddy_feeds 2 | -- Description: Remove buddy feed tables 3 | -- Date: 2024-12-28 4 | 5 | DROP TABLE IF EXISTS buddy_feed_subscriptions; 6 | DROP TABLE IF EXISTS buddy_feed_items; 7 | DROP TABLE IF EXISTS buddy_feeds; 8 | -------------------------------------------------------------------------------- /state/migrations/0019_api_analytics.down.sql: -------------------------------------------------------------------------------- 1 | -- Rollback Migration: 0018_api_analytics 2 | -- Description: Remove Web API usage analytics tables 3 | -- Date: 2024-12-28 4 | 5 | DROP TABLE IF EXISTS api_quotas; 6 | DROP TABLE IF EXISTS api_usage_stats; 7 | DROP TABLE IF EXISTS api_usage_logs; 8 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | round: down 3 | range: 70..79 4 | status: 5 | project: 6 | default: 7 | informational: true 8 | patch: 9 | default: 10 | informational: true 11 | ignore: 12 | - server/webapi 13 | - state/webapi* 14 | - state/web_api* -------------------------------------------------------------------------------- /state/migrations/0024_fix_warn_timestamp.up.sql: -------------------------------------------------------------------------------- 1 | -- Change lastWarnUpdate from DATETIME to INTEGER (Unix timestamp) 2 | -- The old data is corrupted (Go string representation), so we'll drop and recreate 3 | 4 | ALTER TABLE users DROP COLUMN lastWarnUpdate; 5 | 6 | ALTER TABLE users ADD COLUMN lastWarnUpdate INTEGER NOT NULL DEFAULT 0; 7 | 8 | -------------------------------------------------------------------------------- /state/migrations/0022_web_chat_rooms.down.sql: -------------------------------------------------------------------------------- 1 | -- Rollback migration 0021: Web API Chat Rooms Support 2 | DROP TABLE IF EXISTS web_chat_messages; 3 | DROP TABLE IF EXISTS web_chat_participants; 4 | DROP TABLE IF EXISTS web_chat_sessions; 5 | DROP TABLE IF EXISTS web_chat_rooms; 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /server/oscar/helpers_test.go: -------------------------------------------------------------------------------- 1 | package oscar 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/stretchr/testify/mock" 7 | ) 8 | 9 | // matchContext matches any instance of Context interface. 10 | func matchContext() interface{} { 11 | return mock.MatchedBy(func(ctx any) bool { 12 | _, ok := ctx.(context.Context) 13 | return ok 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /state/migrations/0003_formatted_screen_name.down.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE user 2 | ( 3 | screenName VARCHAR(16) PRIMARY KEY, 4 | authKey TEXT, 5 | strongMD5Pass TEXT, 6 | weakMD5Pass TEXT 7 | ); 8 | 9 | INSERT INTO user 10 | SELECT displayScreenName, 11 | authKey, 12 | strongMD5Pass, 13 | weakMD5Pass 14 | FROM users; 15 | 16 | DROP TABLE users; -------------------------------------------------------------------------------- /state/migrations/0002_hash_columns.down.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE user_backup 2 | ( 3 | screenName VARCHAR(16) PRIMARY KEY, 4 | authKey TEXT, 5 | passHash TEXT 6 | ); 7 | 8 | INSERT INTO user_backup (screenName, authKey, passHash) 9 | SELECT screenName, authKey, strongMD5Pass 10 | FROM user; 11 | 12 | DROP TABLE user; 13 | 14 | ALTER TABLE user_backup 15 | RENAME TO user; 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.25.5-alpine AS builder 2 | 3 | WORKDIR /app 4 | 5 | COPY go.mod go.sum ./ 6 | RUN go mod download 7 | 8 | COPY . . 9 | 10 | RUN go build -o open_oscar_server ./cmd/server 11 | 12 | FROM alpine:latest 13 | 14 | WORKDIR /app 15 | 16 | COPY --from=builder /app/open_oscar_server /app/ 17 | 18 | EXPOSE 5190 8080 9898 1088 19 | 20 | CMD ["/app/open_oscar_server"] 21 | -------------------------------------------------------------------------------- /config/ssl/stunnel.conf: -------------------------------------------------------------------------------- 1 | foreground = yes 2 | debug = 7 3 | 4 | [ssl_proxy] 5 | options = NO_SSLv2 6 | options = NO_SSLv3 7 | options = NO_TLSv1_1 8 | ciphers = ALL 9 | accept = 443 10 | connect = retro-aim-server:1088 11 | cert = /etc/stunnel/certs/server.pem 12 | 13 | [oscar_proxy] 14 | options = NO_SSLv2 15 | options = NO_SSLv3 16 | options = NO_TLSv1_1 17 | ciphers = ALL 18 | accept = 5193 19 | connect = retro-aim-server:5190 20 | cert = /etc/stunnel/certs/server.pem -------------------------------------------------------------------------------- /Dockerfile.certgen: -------------------------------------------------------------------------------- 1 | # use the last version of alpine that has a version of nss-tools with support 2 | # for the legacy dbm format compiled in. 3 | FROM alpine:3.16 4 | 5 | RUN apk add --no-cache \ 6 | openssl \ 7 | nss-tools \ 8 | bash \ 9 | ca-certificates \ 10 | curl 11 | 12 | WORKDIR /certs 13 | 14 | # Set default database type to legacy DBM (instead of sqlite), the only type 15 | # AIM 6 supports. 16 | ENV NSS_DEFAULT_DB_TYPE=dbm 17 | 18 | CMD ["/bin/bash"] 19 | -------------------------------------------------------------------------------- /state/migrations/0002_hash_columns.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE user 2 | RENAME COLUMN passHash TO strongMD5Pass; 3 | 4 | ALTER TABLE user 5 | ADD COLUMN weakMD5Pass TEXT; 6 | 7 | -- The cleartext passwords don't exist, so it's not possible to create weak MD5 8 | -- hashes. Fill in the values with a placeholder value. The administrator will 9 | -- require everyone to reset their passwords if they want to log in with AIM 10 | -- 3.5 thru AIM4.7 11 | UPDATE user 12 | SET weakMD5Pass = strongMD5Pass; -------------------------------------------------------------------------------- /scripts/run_stunnel.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | if [ -z "$1" ]; then 6 | echo "Usage: $0 /path/to/server.pem" 7 | exit 1 8 | fi 9 | 10 | PEM_PATH="$1" 11 | 12 | docker run --rm -it \ 13 | --add-host=host.docker.internal:host-gateway \ 14 | --add-host=retro-aim-server:host-gateway \ 15 | -v "$PEM_PATH:/etc/stunnel/certs/server.pem:ro" \ 16 | -v "$(pwd)/config/ssl/stunnel.conf:/etc/stunnel/stunnel.conf:ro" \ 17 | -p 443:443 \ 18 | -p 5193:5193 \ 19 | ras-stunnel:5.75-openssl-1.0.2u stunnel.conf -------------------------------------------------------------------------------- /state/migrations/0005_user_settings.down.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE users_backup 2 | ( 3 | identScreenName VARCHAR(16) PRIMARY KEY, 4 | displayScreenName TEXT, 5 | authKey TEXT, 6 | strongMD5Pass TEXT, 7 | weakMD5Pass TEXT 8 | ); 9 | 10 | INSERT INTO users_backup (identScreenName, displayScreenName, authKey, strongMD5Pass, weakMD5Pass) 11 | SELECT identScreenName, displayScreenName, authKey, strongMD5Pass, weakMD5Pass 12 | FROM users; 13 | 14 | DROP TABLE users; 15 | 16 | ALTER TABLE users_backup 17 | RENAME TO users; 18 | -------------------------------------------------------------------------------- /state/migrations/0016_webapi_tokens.up.sql: -------------------------------------------------------------------------------- 1 | -- Create table for storing Web API authentication tokens 2 | CREATE TABLE IF NOT EXISTS webapi_tokens ( 3 | token TEXT PRIMARY KEY, 4 | screen_name TEXT NOT NULL, 5 | expires_at TIMESTAMP NOT NULL, 6 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 7 | ); 8 | 9 | -- Index for cleaning up expired tokens 10 | CREATE INDEX IF NOT EXISTS idx_webapi_tokens_expires_at ON webapi_tokens(expires_at); 11 | 12 | -- Index for looking up tokens by screen name 13 | CREATE INDEX IF NOT EXISTS idx_webapi_tokens_screen_name ON webapi_tokens(screen_name); 14 | -------------------------------------------------------------------------------- /config/ras.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Open OSCAR Server 3 | 4 | [Service] 5 | Type=simple 6 | User=ras 7 | Group=ras 8 | WorkingDirectory=/opt/ras 9 | StateDirectory=ras 10 | Environment="API_LISTENER=127.0.0.1:8080" 11 | Environment="OSCAR_ADVERTISED_LISTENERS_PLAIN=EXTERNAL://127.0.0.1:5190" 12 | Environment="OSCAR_LISTENERS=EXTERNAL://0.0.0.0:5190" 13 | Environment="TOC_LISTENERS=0.0.0.0:9898" 14 | Environment="DB_PATH=/var/lib/ras/oscar.sqlite" 15 | Environment="DISABLE_AUTH=true" 16 | Environment="LOG_LEVEL=info" 17 | ExecStart=/opt/ras/open_oscar_server 18 | Restart=on-failure 19 | 20 | [Install] 21 | WantedBy=multi-user.target 22 | -------------------------------------------------------------------------------- /state/migrations/0010_feedbag_pd.down.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE feedbag_new 2 | ( 3 | screenName VARCHAR(16), 4 | groupID INTEGER, 5 | itemID INTEGER, 6 | classID INTEGER, 7 | name TEXT, 8 | attributes BLOB, 9 | lastModified INTEGER, 10 | UNIQUE (screenName, groupID, itemID) 11 | ); 12 | 13 | INSERT INTO feedbag_new (screenName, groupID, itemID, classID, name, attributes, lastModified) 14 | SELECT screenName, groupID, itemID, classID, name, attributes, lastModified 15 | FROM feedbag; 16 | 17 | DROP TABLE feedbag; 18 | 19 | ALTER TABLE feedbag_new 20 | RENAME TO feedbag; 21 | 22 | DROP TABLE buddyListMode; 23 | DROP TABLE clientSideBuddyList; 24 | -------------------------------------------------------------------------------- /wire/snacs_string_test.go: -------------------------------------------------------------------------------- 1 | package wire 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestFoodGroupName_HappyPath(t *testing.T) { 9 | assert.Equal(t, "OService", FoodGroupName(OService)) 10 | } 11 | 12 | func TestFoodGroupName_InvalidFoodGroup(t *testing.T) { 13 | assert.Equal(t, "unknown", FoodGroupName(2142)) 14 | } 15 | 16 | func TestSubGroupName_HappyPath(t *testing.T) { 17 | assert.Equal(t, "OServiceServiceRequest", SubGroupName(OService, OServiceServiceRequest)) 18 | } 19 | 20 | func TestSubGroupName_InvalidFoodGroup(t *testing.T) { 21 | assert.Equal(t, "unknown", SubGroupName(2142, OServiceServiceRequest)) 22 | } 23 | -------------------------------------------------------------------------------- /state/migrations/0026_offline_message_fk_index.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users 2 | DROP COLUMN offlineMsgCount; 3 | 4 | DROP INDEX IF EXISTS idx_offlineMessage_sender; 5 | DROP INDEX IF EXISTS idx_offlineMessage_recipient; 6 | 7 | ALTER TABLE offlineMessage 8 | RENAME TO offlineMessage_new; 9 | 10 | CREATE TABLE offlineMessage 11 | ( 12 | sender VARCHAR(16) NOT NULL, 13 | recipient VARCHAR(16) NOT NULL, 14 | message BLOB NOT NULL, 15 | sent TIMESTAMP NOT NULL 16 | ); 17 | 18 | INSERT INTO offlineMessage (sender, recipient, message, sent) 19 | SELECT sender, recipient, message, sent 20 | FROM offlineMessage_new; 21 | 22 | DROP TABLE offlineMessage_new; 23 | -------------------------------------------------------------------------------- /state/migrations/0001_initialize_schema.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS user 2 | ( 3 | screenName VARCHAR(16) PRIMARY KEY, 4 | authKey TEXT, 5 | passHash TEXT 6 | ); 7 | 8 | CREATE TABLE IF NOT EXISTS feedbag 9 | ( 10 | screenName VARCHAR(16), 11 | groupID INTEGER, 12 | itemID INTEGER, 13 | classID INTEGER, 14 | name TEXT, 15 | attributes BLOB, 16 | lastModified INTEGER, 17 | UNIQUE (screenName, groupID, itemID) 18 | ); 19 | 20 | CREATE TABLE IF NOT EXISTS profile 21 | ( 22 | screenName VARCHAR(16) PRIMARY KEY, 23 | body TEXT 24 | ); 25 | 26 | CREATE TABLE IF NOT EXISTS bartItem 27 | ( 28 | hash CHAR(16) PRIMARY KEY, 29 | body BLOB 30 | ); -------------------------------------------------------------------------------- /foodgroup/stats_test.go: -------------------------------------------------------------------------------- 1 | package foodgroup 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/mk6i/retro-aim-server/wire" 10 | ) 11 | 12 | func TestStatsService_ReportEvents(t *testing.T) { 13 | svc := NewStatsService() 14 | 15 | frame := wire.SNACFrame{ 16 | RequestID: 1234, 17 | } 18 | body := wire.SNAC_0x0B_0x03_StatsReportEvents{} 19 | 20 | have := svc.ReportEvents(context.Background(), frame, body) 21 | 22 | want := wire.SNACMessage{ 23 | Frame: wire.SNACFrame{ 24 | FoodGroup: wire.Stats, 25 | SubGroup: wire.StatsReportAck, 26 | RequestID: 1234, 27 | }, 28 | Body: wire.SNAC_0x0B_0x04_StatsReportAck{}, 29 | } 30 | 31 | assert.Equal(t, want, have) 32 | } 33 | -------------------------------------------------------------------------------- /state/migrations/0003_formatted_screen_name.up.sql: -------------------------------------------------------------------------------- 1 | -- call the new table "users" since it's not a reserved word that we'll need to 2 | -- deal with for postgres in the future. 3 | CREATE TABLE users 4 | ( 5 | identScreenName VARCHAR(16) PRIMARY KEY, 6 | displayScreenName TEXT, 7 | authKey TEXT, 8 | strongMD5Pass TEXT, 9 | weakMD5Pass TEXT 10 | ); 11 | 12 | INSERT INTO users 13 | SELECT LOWER(REPLACE(screenName, ' ', '')), 14 | screenName, 15 | authKey, 16 | strongMD5Pass, 17 | weakMD5Pass 18 | FROM user; 19 | 20 | DROP TABLE user; 21 | 22 | UPDATE feedbag 23 | SET name = LOWER(REPLACE(name, ' ', '')) 24 | WHERE classID IN (0, 2, 3); 25 | 26 | UPDATE feedbag 27 | SET screenName = LOWER(REPLACE(screenName, ' ', '')); -------------------------------------------------------------------------------- /state/migrations/0023_web_api_keys.up.sql: -------------------------------------------------------------------------------- 1 | -- Create table for Web API authentication keys 2 | CREATE TABLE IF NOT EXISTS web_api_keys 3 | ( 4 | dev_id VARCHAR(255) PRIMARY KEY, 5 | dev_key VARCHAR(255) UNIQUE NOT NULL, 6 | app_name VARCHAR(255) NOT NULL, 7 | created_at INTEGER NOT NULL, 8 | last_used INTEGER, 9 | is_active BOOLEAN DEFAULT 1, 10 | rate_limit INTEGER DEFAULT 60, 11 | allowed_origins TEXT, -- JSON array of allowed CORS origins 12 | capabilities TEXT -- JSON array of enabled features/endpoints 13 | ); 14 | 15 | -- Create indexes for efficient lookups 16 | CREATE INDEX idx_web_api_keys_dev_key ON web_api_keys(dev_key); 17 | CREATE INDEX idx_web_api_keys_is_active ON web_api_keys(is_active); 18 | 19 | -------------------------------------------------------------------------------- /scripts/run_dev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # This script launches Open OSCAR Server using go run with the environment vars 3 | # defined in config/settings.env under MacOS/Linux. The script can be run from 4 | # any working directory--it assumes the location of config/command files 5 | # relative to the path of this script. 6 | set -e 7 | 8 | SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) 9 | DEFAULT_ENV_FILE="$SCRIPT_DIR/../config/ssl/settings.env" 10 | 11 | if [ "$#" -gt 1 ]; then 12 | echo "Usage: $0 [path/to/settings.env]" 13 | exit 1 14 | fi 15 | 16 | if [ "$#" -eq 1 ]; then 17 | ENV_FILE="$1" 18 | else 19 | ENV_FILE="$DEFAULT_ENV_FILE" 20 | fi 21 | REPO_ROOT="$SCRIPT_DIR/.." 22 | 23 | # Run Open OSCAR Server from repo root. 24 | cd "$REPO_ROOT" 25 | go run -v ./cmd/server -config "$ENV_FILE" -------------------------------------------------------------------------------- /foodgroup/stats.go: -------------------------------------------------------------------------------- 1 | package foodgroup 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/mk6i/retro-aim-server/wire" 7 | ) 8 | 9 | func NewStatsService() StatsService { 10 | return StatsService{} 11 | } 12 | 13 | type StatsService struct { 14 | } 15 | 16 | // ReportEvents handles incoming stats events by acknowledging them without 17 | // processing. This is a no-op implementation to satisfy the client's 18 | // expectation of a response. 19 | func (s StatsService) ReportEvents(ctx context.Context, inFrame wire.SNACFrame, _ wire.SNAC_0x0B_0x03_StatsReportEvents) wire.SNACMessage { 20 | return wire.SNACMessage{ 21 | Frame: wire.SNACFrame{ 22 | FoodGroup: wire.Stats, 23 | SubGroup: wire.StatsReportAck, 24 | RequestID: inFrame.RequestID, 25 | }, 26 | Body: wire.SNAC_0x0B_0x04_StatsReportAck{}, 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /state/chat_test.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/mk6i/retro-aim-server/wire" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestChatRoom_TLVList(t *testing.T) { 12 | room := NewChatRoom("chat-room-name", NewIdentScreenName(""), PublicExchange) 13 | 14 | have := room.TLVList() 15 | want := []wire.TLV{ 16 | wire.NewTLVBE(wire.ChatRoomTLVFlags, uint16(15)), 17 | wire.NewTLVBE(wire.ChatRoomTLVCreateTime, uint32(room.createTime.Unix())), 18 | wire.NewTLVBE(wire.ChatRoomTLVMaxMsgLen, uint16(1024)), 19 | wire.NewTLVBE(wire.ChatRoomTLVMaxOccupancy, uint16(100)), 20 | wire.NewTLVBE(wire.ChatRoomTLVNavCreatePerms, uint8(2)), 21 | wire.NewTLVBE(wire.ChatRoomTLVFullyQualifiedName, room.name), 22 | wire.NewTLVBE(wire.ChatRoomTLVRoomName, room.name), 23 | wire.NewTLVBE(wire.ChatRoomTLVMaxMsgVisLen, uint16(1024)), 24 | } 25 | 26 | assert.Equal(t, want, have) 27 | } 28 | -------------------------------------------------------------------------------- /state/migrations/0010_feedbag_pd.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE feedbag 2 | ADD COLUMN pdMode int DEFAULT 0; 3 | 4 | UPDATE feedbag 5 | SET pdMode = 6 | CASE 7 | WHEN instr(hex(attributes), '00CA000101') > 0 THEN 1 8 | WHEN instr(hex(attributes), '00CA000102') > 0 THEN 2 9 | WHEN instr(hex(attributes), '00CA000103') > 0 THEN 3 10 | WHEN instr(hex(attributes), '00CA000104') > 0 THEN 4 11 | WHEN instr(hex(attributes), '00CA000105') > 0 THEN 5 12 | END 13 | WHERE classID = 4; 14 | 15 | CREATE TABLE buddyListMode 16 | ( 17 | screenName VARCHAR(16), 18 | clientSidePDMode INTEGER DEFAULT 0, 19 | useFeedbag BOOLEAN DEFAULT false, 20 | PRIMARY KEY (screenName) 21 | ); 22 | 23 | CREATE TABLE clientSideBuddyList 24 | ( 25 | me VARCHAR(16), 26 | them VARCHAR(16), 27 | isBuddy BOOLEAN DEFAULT false, 28 | isPermit BOOLEAN DEFAULT false, 29 | isDeny BOOLEAN DEFAULT false, 30 | PRIMARY KEY (me, them) 31 | ); -------------------------------------------------------------------------------- /state/migrations/0026_offline_message_fk_index.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE offlineMessage 2 | RENAME TO offlineMessage_old; 3 | 4 | CREATE TABLE offlineMessage 5 | ( 6 | sender VARCHAR(16) NOT NULL, 7 | recipient VARCHAR(16) NOT NULL, 8 | message BLOB NOT NULL, 9 | sent TIMESTAMP NOT NULL, 10 | FOREIGN KEY (sender) REFERENCES users (identScreenName) ON DELETE CASCADE ON UPDATE CASCADE, 11 | FOREIGN KEY (recipient) REFERENCES users (identScreenName) ON DELETE CASCADE ON UPDATE CASCADE 12 | ); 13 | 14 | INSERT INTO offlineMessage (sender, recipient, message, sent) 15 | SELECT sender, recipient, message, sent 16 | FROM offlineMessage_old; 17 | 18 | DROP TABLE offlineMessage_old; 19 | 20 | CREATE INDEX idx_offlineMessage_sender ON offlineMessage (sender); 21 | CREATE INDEX idx_offlineMessage_recipient ON offlineMessage (recipient); 22 | 23 | ALTER TABLE users 24 | ADD COLUMN offlineMsgCount INTEGER NOT NULL DEFAULT 0; 25 | -------------------------------------------------------------------------------- /cmd/snac_buster/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | 7 | "github.com/mk6i/retro-aim-server/wire" 8 | ) 9 | 10 | func main() { 11 | 12 | b := []byte{} 13 | 14 | flap := wire.FLAPFrame{} 15 | err := wire.UnmarshalBE(&flap, bytes.NewReader(b)) 16 | if err != nil { 17 | err = fmt.Errorf("unable to unmarshal FLAP frame: %w", err) 18 | } 19 | 20 | rd := bytes.NewBuffer(flap.Payload) 21 | snac := wire.SNACFrame{} 22 | wire.UnmarshalBE(&snac, rd) 23 | 24 | printByteSlice(rd.Bytes()) 25 | //snacBody := wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate{} 26 | //wire.UnmarshalBE(&snacBody, rd) 27 | ////fmt.Println(snacBody) 28 | // 29 | //fmt.Println() 30 | // 31 | //for _, tlv := range snacBody.TLVList { 32 | // fmt.Printf("0x%x\t", tlv.Tag) 33 | // printByteSlice(tlv.Value) 34 | // fmt.Println() 35 | //} 36 | } 37 | 38 | func printByteSlice(data []byte) { 39 | fmt.Print("[]byte{") 40 | for i, b := range data { 41 | if i > 0 { 42 | fmt.Print(", ") 43 | } 44 | fmt.Printf("0x%02X", b) 45 | } 46 | fmt.Println("}") 47 | } 48 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request ✨ 3 | about: Use this template to suggest new features or improvements for Open OSCAR Server. 4 | 5 | title: '' 6 | labels: enhancement 7 | assignees: '' 8 | 9 | --- 10 | 11 | 17 | 18 | ### Description of the Feature 19 | 20 | 21 | ### Use Case 22 | 23 | 24 | ### Potential Challenges 25 | 26 | 27 | ### Additional Information 28 | 29 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v5 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v6 21 | with: 22 | go-version: '1.25.5' 23 | 24 | - name: Go Format 25 | run: | 26 | gofmt -s -l . 27 | if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then exit 1; fi 28 | 29 | - name: Go Vet 30 | run: go vet ./... 31 | 32 | - name: Build 33 | run: go build -v ./... 34 | 35 | - name: Test 36 | run: go test -v -race -coverprofile=coverage.out -covermode=atomic ./... 37 | 38 | - name: Upload coverage reports to Codecov 39 | uses: codecov/codecov-action@v5 40 | with: 41 | token: ${{ secrets.CODECOV_TOKEN }} -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 mk6i 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /state/migrations/0017_web_preferences.up.sql: -------------------------------------------------------------------------------- 1 | -- Create table for Web API user preferences 2 | CREATE TABLE IF NOT EXISTS web_preferences 3 | ( 4 | screen_name VARCHAR(16) PRIMARY KEY, 5 | preferences TEXT, -- JSON object of preference key-value pairs 6 | created_at INTEGER NOT NULL, 7 | updated_at INTEGER NOT NULL 8 | ); 9 | 10 | -- Create index for efficient lookups 11 | CREATE INDEX idx_web_preferences_screen_name ON web_preferences(screen_name); 12 | 13 | -- Ensure buddyListMode table exists (for PD mode storage) 14 | -- This should already exist from migration 0010, but we'll add IF NOT EXISTS for safety 15 | CREATE TABLE IF NOT EXISTS buddyListMode 16 | ( 17 | screenName VARCHAR(16), 18 | clientSidePDMode INTEGER DEFAULT 0, 19 | useFeedbag BOOLEAN DEFAULT false, 20 | PRIMARY KEY (screenName) 21 | ); 22 | 23 | -- Ensure clientSideBuddyList table exists (for permit/deny lists) 24 | -- This should already exist from migration 0010, but we'll add IF NOT EXISTS for safety 25 | CREATE TABLE IF NOT EXISTS clientSideBuddyList 26 | ( 27 | me VARCHAR(16), 28 | them VARCHAR(16), 29 | isBuddy BOOLEAN DEFAULT false, 30 | isPermit BOOLEAN DEFAULT false, 31 | isDeny BOOLEAN DEFAULT false, 32 | PRIMARY KEY (me, them) 33 | ); 34 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mk6i/retro-aim-server 2 | 3 | go 1.24.2 4 | 5 | require ( 6 | github.com/breign/goAMF3 v1.0.1-0.20250916173039-e43798221950 7 | github.com/golang-migrate/migrate/v4 v4.19.1 8 | github.com/google/uuid v1.6.0 9 | github.com/joho/godotenv v1.5.1 10 | github.com/kelseyhightower/envconfig v1.4.0 11 | github.com/mitchellh/go-wordwrap v1.0.1 12 | github.com/patrickmn/go-cache v2.1.0+incompatible 13 | github.com/stretchr/testify v1.11.1 14 | golang.org/x/net v0.47.0 15 | golang.org/x/sync v0.18.0 16 | golang.org/x/time v0.14.0 17 | modernc.org/sqlite v1.40.1 18 | ) 19 | 20 | require ( 21 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 22 | github.com/dustin/go-humanize v1.0.1 // indirect 23 | github.com/mattn/go-isatty v0.0.20 // indirect 24 | github.com/ncruces/go-strftime v1.0.0 // indirect 25 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 26 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 27 | github.com/stretchr/objx v0.5.3 // indirect 28 | golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 // indirect 29 | golang.org/x/sys v0.38.0 // indirect 30 | gopkg.in/yaml.v3 v3.0.1 // indirect 31 | modernc.org/libc v1.67.1 // indirect 32 | modernc.org/mathutil v1.7.1 // indirect 33 | modernc.org/memory v1.11.0 // indirect 34 | ) 35 | -------------------------------------------------------------------------------- /state/migrations/0018_oscar_bridge_sessions.up.sql: -------------------------------------------------------------------------------- 1 | -- Create table for storing WebAPI to OSCAR bridge sessions 2 | -- This table maps WebAPI sessions to OSCAR authentication cookies 3 | -- and tracks connection details for bridged sessions 4 | 5 | CREATE TABLE IF NOT EXISTS oscar_bridge_sessions ( 6 | -- WebAPI session identifier (aimsid) 7 | web_session_id VARCHAR(64) PRIMARY KEY, 8 | 9 | -- OSCAR authentication cookie (hex encoded) 10 | oscar_cookie BLOB NOT NULL, 11 | 12 | -- BOS server connection details 13 | bos_host VARCHAR(255) NOT NULL, 14 | bos_port INTEGER NOT NULL, 15 | use_ssl BOOLEAN DEFAULT FALSE, 16 | 17 | -- Screen name associated with the session 18 | screen_name VARCHAR(97) NOT NULL, 19 | 20 | -- Session metadata 21 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 22 | last_accessed TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 23 | 24 | -- Optional: Track client info 25 | client_name VARCHAR(255), 26 | client_version VARCHAR(50) 27 | ); 28 | 29 | -- Create index for quick lookups by screen name 30 | CREATE INDEX idx_oscar_bridge_screen_name ON oscar_bridge_sessions(screen_name); 31 | 32 | -- Create index for cleanup of old sessions 33 | CREATE INDEX idx_oscar_bridge_last_accessed ON oscar_bridge_sessions(last_accessed); 34 | 35 | -- Optional: Create a trigger to auto-update last_accessed on SELECT/UPDATE 36 | -- (SQLite doesn't support this directly, would need application logic) 37 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | cert-gen: 3 | image: ras-certgen:latest 4 | volumes: 5 | - ./certs:/work/certs 6 | working_dir: /work/certs 7 | entrypoint: [ "/bin/sh", "-c" ] 8 | environment: 9 | - OSCAR_HOST=${OSCAR_HOST} 10 | command: 11 | - "openssl req -x509 -newkey rsa:1024 12 | -keyout key.pem 13 | -out cert.pem 14 | -sha256 -days 365 -nodes 15 | -subj \"/CN=${OSCAR_HOST}\" 16 | && cat cert.pem key.pem > server.pem 17 | && rm cert.pem key.pem" 18 | 19 | nss-gen: 20 | image: ras-certgen:latest 21 | volumes: 22 | - ./certs:/work/certs 23 | working_dir: /work/certs 24 | entrypoint: [ "/bin/sh", "-c" ] 25 | command: 26 | - "rm -rf nss 27 | && mkdir -p nss 28 | && certutil -N -d nss --empty-password 29 | && certutil -A -n \"RAS\" -t \"CT,,C\" -i server.pem -d nss" 30 | 31 | retro-aim-server: 32 | image: ras:latest 33 | ports: 34 | - "5190:5190" 35 | - "8080:8080" 36 | - "9898:9898" 37 | - "1088:1088" 38 | env_file: 39 | - ./config/ssl/settings.env 40 | volumes: 41 | - .:/project 42 | working_dir: /project 43 | 44 | stunnel: 45 | image: ras-stunnel:5.75-openssl-1.0.2u 46 | ports: 47 | - "443:443" 48 | - "5193:5193" 49 | volumes: 50 | - ./config/ssl/stunnel.conf:/etc/stunnel/stunnel.conf:ro 51 | - ./certs:/etc/stunnel/certs:ro 52 | command: stunnel.conf 53 | -------------------------------------------------------------------------------- /state/migrations/0021_vanity_urls.up.sql: -------------------------------------------------------------------------------- 1 | -- Migration: 0020_vanity_urls 2 | -- Description: Create tables for vanity URL management 3 | -- Date: 2024-12-28 4 | 5 | -- Create vanity URLs table for custom user URLs 6 | CREATE TABLE IF NOT EXISTS vanity_urls ( 7 | screen_name VARCHAR(16) PRIMARY KEY, 8 | vanity_url VARCHAR(255) UNIQUE NOT NULL, 9 | display_name VARCHAR(100), 10 | bio TEXT, 11 | location VARCHAR(100), 12 | website VARCHAR(255), 13 | created_at INTEGER NOT NULL, 14 | updated_at INTEGER NOT NULL, 15 | is_active BOOLEAN DEFAULT TRUE, 16 | click_count INTEGER DEFAULT 0, 17 | last_accessed INTEGER 18 | ); 19 | 20 | -- Create indexes for efficient lookups 21 | CREATE INDEX idx_vanity_urls_url ON vanity_urls(vanity_url); 22 | CREATE INDEX idx_vanity_urls_active ON vanity_urls(is_active); 23 | CREATE INDEX idx_vanity_urls_created ON vanity_urls(created_at); 24 | 25 | -- Create vanity URL redirects table for tracking and analytics 26 | CREATE TABLE IF NOT EXISTS vanity_url_redirects ( 27 | id INTEGER PRIMARY KEY AUTOINCREMENT, 28 | vanity_url VARCHAR(255) NOT NULL, 29 | accessed_at INTEGER NOT NULL, 30 | ip_address VARCHAR(45), 31 | user_agent TEXT, 32 | referer TEXT, 33 | FOREIGN KEY (vanity_url) REFERENCES vanity_urls(vanity_url) ON DELETE CASCADE 34 | ); 35 | 36 | -- Create index for redirect analytics 37 | CREATE INDEX idx_vanity_redirects_url ON vanity_url_redirects(vanity_url); 38 | CREATE INDEX idx_vanity_redirects_time ON vanity_url_redirects(accessed_at); 39 | -------------------------------------------------------------------------------- /docs/SYSTEMD.md: -------------------------------------------------------------------------------- 1 | # Configuring Open OSCAR Server With systemd 2 | 3 | This document details the configuration of Open OSCAR Server to run as an unprivileged user with `systemd` managing it as 4 | a production service. 5 | 6 | 1. **Download Open OSCAR Server** 7 | 8 | Grab the latest Linux release from the [releases page](https://github.com/mk6i/open-oscar-server/releases) 9 | 10 | 2. **Create the ras user and group** 11 | 12 | Run the following commands: 13 | 14 | ```shell 15 | sudo useradd ras 16 | sudo mkdir -p /opt/ras 17 | ``` 18 | 19 | 3. **Extract the archive** 20 | 21 | Extract the archive using the usual `tar` invocation, and move `open_oscar_server` into `/opt/ras` 22 | 23 | 4. **Set Ownership and Permissions** 24 | 25 | ```shell 26 | sudo chown -R ras:ras /opt/ras 27 | sudo chmod -R o-rx /opt/ras 28 | ``` 29 | 30 | 5. **Copy the systemd service** 31 | 32 | Place the `ras.service` file in `/etc/systemd/system` 33 | 34 | 6. **Reload systemd** 35 | 36 | ```shell 37 | sudo systemctl daemon-reload 38 | ``` 39 | 40 | 7. **Enable and start the service** 41 | 42 | ```shell 43 | sudo systemctl enable --now ras.service 44 | ``` 45 | 46 | 8. **Make sure the service is running** 47 | 48 | ```shell 49 | sudo systemctl status ras.service 50 | sudo journalctl -xeu ras.service 51 | ``` 52 | 53 | Note that the `systemd` service defines the configuration for Open OSCAR Server directly, bypassing the `settings.env` 54 | config. Customizations may be performed in `/etc/systemd/system/ras.service`. 55 | -------------------------------------------------------------------------------- /server/webapi/handlers/expressions.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "log/slog" 5 | "net/http" 6 | ) 7 | 8 | // ExpressionsHandler handles Web AIM API expressions/buddy icon endpoints. 9 | type ExpressionsHandler struct { 10 | Logger *slog.Logger 11 | } 12 | 13 | // NewExpressionsHandler creates a new ExpressionsHandler. 14 | func NewExpressionsHandler(logger *slog.Logger) *ExpressionsHandler { 15 | return &ExpressionsHandler{ 16 | Logger: logger, 17 | } 18 | } 19 | 20 | // Get handles GET /expressions/get requests for buddy icons and expressions. 21 | func (h *ExpressionsHandler) Get(w http.ResponseWriter, r *http.Request) { 22 | // Parse parameters 23 | format := r.URL.Query().Get("f") 24 | targetUser := r.URL.Query().Get("t") 25 | expressionType := r.URL.Query().Get("type") 26 | 27 | h.Logger.Debug("expressions/get request", 28 | "format", format, 29 | "target", targetUser, 30 | "type", expressionType, 31 | ) 32 | 33 | // For redirect format, return a 404 (no buddy icon) 34 | // In a real implementation, this would redirect to the actual icon URL 35 | if format == "redirect" { 36 | w.WriteHeader(http.StatusNotFound) 37 | return 38 | } 39 | 40 | // For other formats, return an empty response indicating no expressions 41 | response := map[string]interface{}{ 42 | "response": map[string]interface{}{ 43 | "statusCode": 200, 44 | "statusText": "OK", 45 | "data": map[string]interface{}{ 46 | "expressions": []interface{}{}, 47 | }, 48 | }, 49 | } 50 | 51 | // Send response in requested format 52 | SendResponse(w, r, response, h.Logger) 53 | } 54 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 🐞 3 | about: Use this template to report bugs in Open OSCAR Server. 4 | 5 | title: '' 6 | labels: bug 7 | assignees: '' 8 | 9 | --- 10 | 11 | 15 | 16 | 22 | 23 | ### Subject of the Issue 24 | 25 | 26 | ### Deployment Environment 27 | * **Open OSCAR Server Version**: 28 | * **Installation Method**: 29 | * **Client(s) Used**: 30 | * **Other Relevant Details**: 31 | 32 | ### Steps to Reproduce 33 | 34 | 35 | ### Expected Behavior 36 | 37 | 38 | ### Actual Behavior 39 | 40 | 41 | ### Troubleshooting Data 42 | 43 | -------------------------------------------------------------------------------- /foodgroup/user_lookup.go: -------------------------------------------------------------------------------- 1 | package foodgroup 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/mk6i/retro-aim-server/state" 8 | "github.com/mk6i/retro-aim-server/wire" 9 | ) 10 | 11 | // NewUserLookupService returns a new instance of UserLookupService. 12 | func NewUserLookupService(profileManager ProfileManager) UserLookupService { 13 | return UserLookupService{ 14 | profileManager: profileManager, 15 | } 16 | } 17 | 18 | // UserLookupService implements the UserLookup food group. 19 | type UserLookupService struct { 20 | profileManager ProfileManager 21 | } 22 | 23 | // FindByEmail searches for a user by email address. 24 | func (s UserLookupService) FindByEmail(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0A_0x02_UserLookupFindByEmail) (wire.SNACMessage, error) { 25 | user, err := s.profileManager.FindByAIMEmail(ctx, string(inBody.Email)) 26 | 27 | switch { 28 | case errors.Is(err, state.ErrNoUser): 29 | return wire.SNACMessage{ 30 | Frame: wire.SNACFrame{ 31 | FoodGroup: wire.UserLookup, 32 | SubGroup: wire.UserLookupErr, 33 | RequestID: inFrame.RequestID, 34 | }, 35 | Body: wire.UserLookupErrNoUserFound, 36 | }, nil 37 | case err != nil: 38 | return wire.SNACMessage{}, err 39 | } 40 | 41 | return wire.SNACMessage{ 42 | Frame: wire.SNACFrame{ 43 | FoodGroup: wire.UserLookup, 44 | SubGroup: wire.UserLookupFindReply, 45 | RequestID: inFrame.RequestID, 46 | }, 47 | Body: wire.SNAC_0x0A_0x03_UserLookupFindReply{ 48 | TLVRestBlock: wire.TLVRestBlock{ 49 | TLVList: wire.TLVList{ 50 | wire.NewTLVBE(wire.UserLookupTLVEmailAddress, user.DisplayScreenName), 51 | }, 52 | }, 53 | }, 54 | }, nil 55 | } 56 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | 7 | builds: 8 | - id: linux 9 | binary: open_oscar_server 10 | goos: 11 | - linux 12 | goarch: 13 | - amd64 14 | - arm 15 | goarm: 16 | - "7" 17 | main: ./cmd/server 18 | env: 19 | - CGO_ENABLED=0 20 | - id: macos 21 | binary: open_oscar_server 22 | goos: 23 | - darwin 24 | goarch: 25 | - amd64 26 | - arm64 27 | main: ./cmd/server 28 | env: 29 | - CGO_ENABLED=0 30 | - id: windows 31 | binary: open_oscar_server 32 | goos: 33 | - windows 34 | goarch: 35 | - amd64 36 | main: ./cmd/server 37 | env: 38 | - CGO_ENABLED=0 39 | 40 | archives: 41 | - id: linux 42 | ids: 43 | - linux 44 | formats: 45 | - tar.gz 46 | wrap_in_directory: true 47 | files: 48 | - LICENSE 49 | - src: config/settings.env 50 | strip_parent: true 51 | - src: config/ras.service 52 | strip_parent: true 53 | name_template: >- 54 | {{ .Binary }}.{{ .Version }}.{{ .Os }}. 55 | {{- if eq .Arch "amd64" }}x86_64{{ else }}arm64_arm7_raspberry_pi{{ end }} 56 | - id: macos 57 | ids: 58 | - macos 59 | formats: 60 | - zip 61 | wrap_in_directory: true 62 | files: 63 | - LICENSE 64 | - src: config/settings.env 65 | strip_parent: true 66 | name_template: >- 67 | {{ .Binary }}.{{ .Version }}.macos. 68 | {{- if eq .Arch "amd64" }}intel_x86_64{{ else }}apple_silicon{{ end }} 69 | - id: windows 70 | ids: 71 | - windows 72 | formats: 73 | - zip 74 | wrap_in_directory: true 75 | files: 76 | - LICENSE 77 | - src: config/settings.env 78 | strip_parent: true 79 | name_template: >- 80 | {{ .Binary }}.{{ .Version }}.{{ .Os }}. 81 | {{- if eq .Arch "amd64" }}x86_64 82 | {{- else }}{{ .Arch }}{{ end }} 83 | 84 | release: 85 | draft: true -------------------------------------------------------------------------------- /server/webapi/handler.go: -------------------------------------------------------------------------------- 1 | package webapi 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "net/http" 8 | 9 | "github.com/mk6i/retro-aim-server/state" 10 | "github.com/mk6i/retro-aim-server/wire" 11 | ) 12 | 13 | type Handler struct { 14 | AdminService AdminService 15 | AuthService AuthService 16 | BuddyListRegistry BuddyListRegistry 17 | BuddyService BuddyService 18 | ChatNavService ChatNavService 19 | ChatService ChatService 20 | CookieBaker CookieBaker 21 | DirSearchService DirSearchService 22 | ICBMService ICBMService 23 | LocateService LocateService 24 | Logger *slog.Logger 25 | OServiceService OServiceService 26 | PermitDenyService PermitDenyService 27 | TOCConfigStore TOCConfigStore 28 | SNACRateLimits wire.SNACRateLimits 29 | // New fields for WebAPI handlers 30 | SessionRetriever SessionRetriever 31 | FeedbagRetriever FeedbagRetriever 32 | FeedbagManager FeedbagManager 33 | // Phase 2 additions 34 | MessageRelayer MessageRelayer 35 | OfflineMessageManager OfflineMessageManager 36 | BuddyBroadcaster BuddyBroadcaster 37 | ProfileManager ProfileManager 38 | RelationshipFetcher interface { 39 | Relationship(ctx context.Context, me state.IdentScreenName, them state.IdentScreenName) (state.Relationship, error) 40 | } 41 | // Authentication support 42 | UserManager UserManager 43 | TokenStore TokenStore 44 | // Phase 3 additions 45 | PreferenceManager PreferenceManager 46 | PermitDenyManager PermitDenyManager 47 | // Phase 4 additions for OSCAR Bridge 48 | OSCARBridgeStore OSCARBridgeStore 49 | OSCARConfig OSCARConfig 50 | // Phase 5 additions for buddy list and messaging 51 | BuddyListManager interface{} 52 | // Phase 5 additions for chat rooms 53 | ChatManager *state.WebAPIChatManager 54 | } 55 | 56 | func (h Handler) GetHelloWorldHandler(w http.ResponseWriter, r *http.Request) { 57 | h.Logger.Info("got a request to the root endpoint", "method", r.Method, "path", r.URL.Path) 58 | _, _ = fmt.Fprintf(w, "WebAPI Server Running\n") 59 | } 60 | -------------------------------------------------------------------------------- /docs/RELEASE.md: -------------------------------------------------------------------------------- 1 | # Open OSCAR Server Release Process 2 | 3 | This document explains how the Open OSCAR Server release process works. 4 | 5 | ## Overview 6 | 7 | Open OSCAR Server is built and released to Github using [GoReleaser](https://goreleaser.com/). The release process, which 8 | runs from a local computer (and not a CI/CD process) creates pre-built binaries for several platforms (Windows, macOS, 9 | Linux). 10 | 11 | GoReleaser runs in a Docker container, which provides a hermetic environment that prevents build contamination from the 12 | host environment. 13 | 14 | ### Code Signing Policy 15 | 16 | This project offers signed Windows binaries and does not currently offer signed macOS binaries. This means that macOS 17 | distrusts Open OSCAR Server by default and quarantines the application when you open it. 18 | > If you don't want to bypass this security mechanism, you can [build the project yourself](./building) instead. 19 | 20 | ## Release Procedure 21 | 22 | The following is the procedure that builds Open OSCAR Server and uploads the build artifacts to a Github release. 23 | 24 | 1. **Export Github Personal Access Token (PAT)** 25 | 26 | Export a Github Personal Access Token that has `write:packages` permissions for the Open OSCAR Server repo. 27 | 28 | ```sh 29 | export GITHUB_TOKEN=... 30 | ``` 31 | 32 | 2. **Tag The Build** 33 | 34 | Tag the build using [semantic versioning](https://semver.org/). 35 | ```shell 36 | git tag v0.1.0 37 | git push --tags 38 | ``` 39 | 40 | 3. **Dry-Run Release** 41 | 42 | Execute a dry-run build to make sure all the moving parts work together. Fix any problems that crop up before 43 | continuing. 44 | 45 | ```shell 46 | make release-dry-run 47 | ``` 48 | 49 | 4. **Release It!** 50 | 51 | Now run the release process. Once its complete, a private draft [release](https://github.com/mk6i/open-oscar-server/releases) 52 | should appear with attached build artifacts. 53 | 54 | ```shell 55 | make release 56 | ``` 57 | 58 | 5. **Sign It!** 59 | 60 | Download the Windows release, sign it, and re-upload the `.zip` to the draft release created in the previous step. 61 | 62 | 6. **Publish It** 63 | 64 | Publish the draft release. -------------------------------------------------------------------------------- /docs/BUILD.md: -------------------------------------------------------------------------------- 1 | # Development: How to Run/Compile Open OSCAR Server 2 | 3 | This guide explains how to set up a development environment for Open OSCAR Server and build/run the application. It 4 | assumes that you have little to no experience with golang. 5 | 6 | ## Dependencies 7 | 8 | Before you can run Open OSCAR Server, set up the following software dependencies. 9 | 10 | ### Golang 11 | 12 | Since Open OSCAR Server is written in go, install the latest version of [golang](https://go.dev/). 13 | 14 | If you're new to go, try [Visual Studio Code](https://code.visualstudio.com) wth the [go plugin](https://code.visualstudio.com/docs/languages/go) 15 | as your first IDE. 16 | 17 | ### Mockery (optional) 18 | 19 | [Mockery](https://github.com/vektra/mockery) is used to generate test mocks. Install this dependency and regenerate the 20 | mocks if you change any interfaces. 21 | 22 | ```shell 23 | go install github.com/vektra/mockery/v2@latest 24 | ``` 25 | 26 | Run the following command in a terminal from the root of the repository in order to regenerate test mocks, 27 | 28 | ```shell 29 | mockery 30 | ``` 31 | 32 | ## Run the Server 33 | 34 | To run the server using `go run`, run the following script from the root of the repository. The default settings can be 35 | modified in `config/settings.env`. 36 | 37 | ```shell 38 | scripts/run_dev.sh 39 | ``` 40 | 41 | ## Build the Server 42 | 43 | To build the server binary: 44 | 45 | ```shell 46 | go build -o open_oscar_server ./cmd/server 47 | ``` 48 | 49 | To run the binary with the settings file: 50 | 51 | ```shell 52 | ./open_oscar_server -config config/settings.env 53 | ``` 54 | 55 | ## Testing 56 | 57 | Open OSCAR Server includes a test suite that must pass before merging new code. To run the unit tests, run the following 58 | command from the root of the repository in a terminal: 59 | 60 | ```shell 61 | go test -race ./... 62 | ``` 63 | 64 | ## Config File Generation 65 | 66 | The config file `config/settings.env` is generated programmatically from the [Config](../config/config.go) struct using 67 | `go generate`. If you want to add or remove application configuration options, first edit the Config struct and then 68 | generate the configuration files by running `make config` from the project root. Do not edit the config files by hand. 69 | -------------------------------------------------------------------------------- /wire/user.go: -------------------------------------------------------------------------------- 1 | package wire 2 | 3 | import ( 4 | "crypto/md5" 5 | "io" 6 | ) 7 | 8 | // WeakMD5PasswordHash hashes password and authKey for AIM v3.5-v4.7. 9 | // 10 | //goland:noinspection ALL 11 | func WeakMD5PasswordHash(pass, authKey string) []byte { 12 | hash := md5.New() 13 | io.WriteString(hash, authKey) 14 | io.WriteString(hash, pass) 15 | io.WriteString(hash, "AOL Instant Messenger (SM)") 16 | return hash.Sum(nil) 17 | } 18 | 19 | // StrongMD5PasswordHash hashes password and authKey for AIM v4.8+. 20 | // 21 | //goland:noinspection ALL 22 | func StrongMD5PasswordHash(pass, authKey string) []byte { 23 | top := md5.New() 24 | io.WriteString(top, pass) 25 | bottom := md5.New() 26 | io.WriteString(bottom, authKey) 27 | bottom.Write(top.Sum(nil)) 28 | io.WriteString(bottom, "AOL Instant Messenger (SM)") 29 | return bottom.Sum(nil) 30 | } 31 | 32 | // RoastOSCARPassword roasts an OSCAR client password. 33 | func RoastOSCARPassword(roastedPass []byte) []byte { 34 | var roastTable = []byte{ 35 | 0xF3, 0x26, 0x81, 0xC4, 0x39, 0x86, 0xDB, 0x92, 36 | 0x71, 0xA3, 0xB9, 0xE6, 0x53, 0x7A, 0x95, 0x7C, 37 | } 38 | return roastPass(roastedPass, roastTable) 39 | } 40 | 41 | // RoastKerberosPassword roasts a Kerberos client password. 42 | func RoastKerberosPassword(roastedPass []byte) []byte { 43 | var roastTable = []byte{ 44 | 0x76, 0x91, 0xc5, 0xe7, 0xd0, 0xd9, 0x95, 0xdd, 45 | 0x9e, 0x2F, 0xea, 0xd8, 0x6B, 0x21, 0xc2, 0xbc, 46 | } 47 | return roastPass(roastedPass, roastTable) 48 | } 49 | 50 | // RoastOSCARJavaPassword roasts a Java OSCAR client password. 51 | func RoastOSCARJavaPassword(roastedPass []byte) []byte { 52 | var roastTable = []byte{ 53 | 0xF3, 0xB3, 0x6C, 0x99, 0x95, 0x3F, 0xAC, 0xB6, 54 | 0xC5, 0xFA, 0x6B, 0x63, 0x69, 0x6C, 0xC3, 0x9A, 55 | } 56 | return roastPass(roastedPass, roastTable) 57 | } 58 | 59 | // RoastTOCPassword roasts a TOC client password. 60 | func RoastTOCPassword(roastedPass []byte) []byte { 61 | return roastPass(roastedPass, []byte("Tic/Toc")) 62 | } 63 | 64 | // roastPass toggles obfuscation/deobfuscates of roastedPass. 65 | func roastPass(roastedPass []byte, roastTable []byte) []byte { 66 | clearPass := make([]byte, len(roastedPass)) 67 | for i := range roastedPass { 68 | clearPass[i] = roastedPass[i] ^ roastTable[i%len(roastTable)] 69 | } 70 | return clearPass 71 | } 72 | -------------------------------------------------------------------------------- /docs/DOCKER.md: -------------------------------------------------------------------------------- 1 | # Open OSCAR Server Docker Setup 2 | 3 | This guide explains how to set up an SSL-enabled instance of Open OSCAR Server using Docker. 4 | 5 | ## Prerequisites 6 | 7 | - Git 8 | - [Docker Desktop](https://docs.docker.com/get-started/get-docker/) 9 | - Unix-like terminal with Makefile installed (use WSL2 for Windows) 10 | 11 | ## Getting Started 12 | 13 | ### 1. Clone the Repository 14 | 15 | ```bash 16 | git clone https://github.com/mk6i/open-oscar-server.git 17 | cd open-oscar-server 18 | ``` 19 | 20 | ### 2. Build Docker Images 21 | 22 | This builds Docker images for: 23 | 24 | - Certificate generation 25 | - SSL termination 26 | - The Open OSCAR Server runtime 27 | 28 | ```bash 29 | make docker-images 30 | ``` 31 | 32 | ### 3. Configure SSL Certificate 33 | 34 | #### Option A: Generate a Self-Signed Certificate 35 | 36 | If you don't have an SSL certificate, you can generate a self-signed certificate. The following creates a certificate 37 | under `certs/server.pem`. 38 | 39 | ```bash 40 | make docker-cert OSCAR_HOST=ras.dev 41 | ``` 42 | 43 | Replace `ras.dev` with the hostname clients will use to connect. 44 | 45 | #### Option B: Use an Existing Certificate 46 | 47 | If you already have an SSL certificate, place the PEM-encoded file at: 48 | 49 | ``` 50 | certs/server.pem 51 | ``` 52 | 53 | ### 4. Generate NSS Certificate Database 54 | 55 | This creates the [NSS certificate database](https://developer.mozilla.org/en-US/docs/Mozilla/Projects/NSS) in 56 | `certs/nss/`, which must be installed on each AIM 6.2+ client. 57 | 58 | ```bash 59 | make docker-nss 60 | ``` 61 | 62 | ### 5. Start the Server 63 | 64 | ```bash 65 | make docker-run OSCAR_HOST=ras.dev 66 | ``` 67 | 68 | Replace `ras.dev` with the hostname clients will use to connect. 69 | 70 | ### 6. Client Configuration 71 | 72 | #### Certificate Database 73 | 74 | Follow the [AIM 6.x client setup instructions](AIM_6_7.md#aim-6265312-setup) to install the `certs/nss/` database on each 75 | client. 76 | 77 | #### Resolving Hostname 78 | 79 | If `OSCAR_HOST` (e.g., `ras.dev`) is not a real domain with DNS configured, you'll need to add it to each client's hosts 80 | file so clients can resolve it. 81 | 82 | - Linux/macOS: `/etc/hosts` 83 | - Windows: `C:\Windows\System32\drivers\etc\hosts` 84 | 85 | Add a line like this, replacing the IP with your server's IP address: 86 | 87 | ``` 88 | 127.0.0.1 ras.dev 89 | ``` 90 | -------------------------------------------------------------------------------- /config/settings.env: -------------------------------------------------------------------------------- 1 | # Network listeners for core OSCAR services. For multi-homed servers, allows 2 | # users to connect from multiple networks. For example, you can allow both LAN 3 | # and Internet clients to connect to the same server using different connection 4 | # settings. 5 | # 6 | # Format: 7 | # - Comma-separated list of [NAME]://[HOSTNAME]:[PORT] 8 | # - Listener names and ports must be unique 9 | # - Listener names are user-defined 10 | # - Each listener needs a listener in OSCAR_ADVERTISED_LISTENERS_PLAIN 11 | # 12 | # Examples: 13 | # // Listen on all interfaces 14 | # LAN://0.0.0.0:5190 15 | # // Separate Internet and LAN config 16 | # WAN://142.250.176.206:5190,LAN://192.168.1.10:5191 17 | export OSCAR_LISTENERS=LOCAL://0.0.0.0:5190 18 | 19 | # Hostnames published by the server that clients connect to for accessing 20 | # various OSCAR services. These hostnames are NOT the bind addresses. For 21 | # multi-homed use servers, allows clients to connect using separate hostnames 22 | # per network. 23 | # 24 | # Format: 25 | # - Comma-separated list of [NAME]://[HOSTNAME]:[PORT] 26 | # - Each listener config must correspond to a config in OSCAR_LISTENERS 27 | # - Clients MUST be able to connect to these hostnames 28 | # 29 | # Examples: 30 | # // Local LAN config, server behind NAT 31 | # LAN://192.168.1.10:5190 32 | # // Separate Internet and LAN config 33 | # WAN://aim.example.com:5190,LAN://192.168.1.10:5191 34 | export OSCAR_ADVERTISED_LISTENERS_PLAIN=LOCAL://127.0.0.1:5190 35 | 36 | # Network listeners for TOC protocol service. 37 | # 38 | # Format: Comma-separated list of hostname:port pairs. 39 | # 40 | # Examples: 41 | # // All interfaces 42 | # 0.0.0.0:9898 43 | # // Multiple listeners 44 | # 0.0.0.0:9898,192.168.1.10:9899 45 | export TOC_LISTENERS=0.0.0.0:9898 46 | 47 | # Network listener for management API binds to. Only 1 listener can be 48 | # specified. (Default 127.0.0.1 restricts to same machine only). 49 | export API_LISTENER=127.0.0.1:8080 50 | 51 | # The path to the SQLite database file. The file and DB schema are auto-created 52 | # if they doesn't exist. 53 | export DB_PATH=oscar.sqlite 54 | 55 | # Disable password check and auto-create new users at login time. Useful for 56 | # quickly creating new accounts during development without having to register 57 | # new users via the management API. 58 | export DISABLE_AUTH=true 59 | 60 | # Set logging granularity. Possible values: 'trace', 'debug', 'info', 'warn', 61 | # 'error'. 62 | export LOG_LEVEL=info 63 | 64 | -------------------------------------------------------------------------------- /state/migrations/0020_buddy_feeds.up.sql: -------------------------------------------------------------------------------- 1 | -- Migration: 0019_buddy_feeds 2 | -- Description: Create tables for buddy feed functionality 3 | -- Date: 2024-12-28 4 | 5 | -- Create buddy feeds table for storing user feed configurations 6 | CREATE TABLE IF NOT EXISTS buddy_feeds ( 7 | id INTEGER PRIMARY KEY AUTOINCREMENT, 8 | screen_name VARCHAR(16) NOT NULL, 9 | feed_type VARCHAR(50) NOT NULL, -- 'rss', 'atom', 'status', 'blog', 'social' 10 | title TEXT, 11 | description TEXT, 12 | link TEXT, 13 | published_at INTEGER NOT NULL, 14 | created_at INTEGER NOT NULL, 15 | updated_at INTEGER NOT NULL, 16 | is_active BOOLEAN DEFAULT TRUE 17 | ); 18 | 19 | -- Create indexes for efficient querying 20 | CREATE INDEX idx_buddy_feeds_screen_name ON buddy_feeds(screen_name); 21 | CREATE INDEX idx_buddy_feeds_published ON buddy_feeds(published_at); 22 | CREATE INDEX idx_buddy_feeds_type ON buddy_feeds(feed_type); 23 | CREATE INDEX idx_buddy_feeds_active ON buddy_feeds(is_active); 24 | 25 | -- Create buddy feed items table for individual feed entries 26 | CREATE TABLE IF NOT EXISTS buddy_feed_items ( 27 | id INTEGER PRIMARY KEY AUTOINCREMENT, 28 | feed_id INTEGER NOT NULL, 29 | title TEXT NOT NULL, 30 | description TEXT, 31 | link TEXT, 32 | guid TEXT, 33 | author VARCHAR(16), -- Screen name of the author 34 | categories TEXT, -- JSON array of categories 35 | published_at INTEGER NOT NULL, 36 | created_at INTEGER NOT NULL, 37 | FOREIGN KEY (feed_id) REFERENCES buddy_feeds(id) ON DELETE CASCADE 38 | ); 39 | 40 | -- Create indexes for feed items 41 | CREATE INDEX idx_feed_items_feed_id ON buddy_feed_items(feed_id); 42 | CREATE INDEX idx_feed_items_published ON buddy_feed_items(published_at); 43 | CREATE INDEX idx_feed_items_guid ON buddy_feed_items(guid); 44 | 45 | -- Create buddy feed subscriptions table for tracking who follows which feeds 46 | CREATE TABLE IF NOT EXISTS buddy_feed_subscriptions ( 47 | id INTEGER PRIMARY KEY AUTOINCREMENT, 48 | subscriber_screen_name VARCHAR(16) NOT NULL, 49 | feed_id INTEGER NOT NULL, 50 | subscribed_at INTEGER NOT NULL, 51 | last_checked_at INTEGER, 52 | FOREIGN KEY (feed_id) REFERENCES buddy_feeds(id) ON DELETE CASCADE, 53 | UNIQUE(subscriber_screen_name, feed_id) 54 | ); 55 | 56 | -- Create indexes for subscriptions 57 | CREATE INDEX idx_feed_subs_subscriber ON buddy_feed_subscriptions(subscriber_screen_name); 58 | CREATE INDEX idx_feed_subs_feed_id ON buddy_feed_subscriptions(feed_id); 59 | -------------------------------------------------------------------------------- /cmd/server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | "time" 11 | 12 | "github.com/joho/godotenv" 13 | "golang.org/x/sync/errgroup" 14 | 15 | "github.com/mk6i/retro-aim-server/server/webapi" 16 | ) 17 | 18 | var ( 19 | // default build fields populated by GoReleaser 20 | version = "dev" 21 | commit = "none" 22 | date = "unknown" 23 | ) 24 | 25 | func init() { 26 | cfgFile := flag.String("config", "settings.env", "Path to config file") 27 | showHelp := flag.Bool("help", false, "Display help") 28 | showVersion := flag.Bool("version", false, "Display build information") 29 | 30 | flag.Parse() 31 | 32 | switch { 33 | case *showVersion: 34 | fmt.Printf("%-10s %s\n", "version:", version) 35 | fmt.Printf("%-10s %s\n", "commit:", commit) 36 | fmt.Printf("%-10s %s\n", "date:", date) 37 | os.Exit(0) 38 | case *showHelp: 39 | flag.PrintDefaults() 40 | os.Exit(0) 41 | } 42 | 43 | // optionally populate environment variables with config file 44 | if err := godotenv.Load(*cfgFile); err != nil { 45 | fmt.Printf("Config file (%s) not found, defaulting to env vars for app config...\n", *cfgFile) 46 | } else { 47 | fmt.Printf("Successfully loaded config file (%s)\n", *cfgFile) 48 | } 49 | } 50 | 51 | func main() { 52 | ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) 53 | defer stop() 54 | 55 | deps, err := MakeCommonDeps() 56 | if err != nil { 57 | fmt.Printf("startup failed: %s\n", err) 58 | os.Exit(1) 59 | } 60 | 61 | g, ctx := errgroup.WithContext(ctx) 62 | 63 | oscar := OSCAR(deps) 64 | g.Go(oscar.ListenAndServe) 65 | 66 | kerb := KerberosAPI(deps) 67 | g.Go(kerb.ListenAndServe) 68 | 69 | api := MgmtAPI(deps) 70 | g.Go(api.ListenAndServe) 71 | 72 | toc := TOC(deps) 73 | g.Go(toc.ListenAndServe) 74 | 75 | var webAPI *webapi.Server 76 | if os.Getenv("ENABLE_WEBAPI") == "1" { 77 | webAPI = WebAPI(deps) 78 | g.Go(webAPI.ListenAndServe) 79 | } 80 | 81 | select { 82 | case <-ctx.Done(): 83 | shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 84 | defer cancel() 85 | _ = oscar.Shutdown(shutdownCtx) 86 | _ = kerb.Shutdown(shutdownCtx) 87 | _ = api.Shutdown(shutdownCtx) 88 | _ = toc.Shutdown(shutdownCtx) 89 | if os.Getenv("ENABLE_WEBAPI") == "1" { 90 | _ = webAPI.Shutdown(shutdownCtx) 91 | } 92 | } 93 | 94 | if err = g.Wait(); err != nil { 95 | deps.logger.Error("server initialization failed", "err", err.Error()) 96 | os.Exit(1) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Dockerfile.stunnel: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Build stage – compile OpenSSL 1.0.2u and stunnel 5.75 3 | ############################################################################### 4 | FROM debian:12.11-slim AS build 5 | 6 | ARG OPENSSL_VERSION=1.0.2u 7 | ARG OPENSSL_TAG=OpenSSL_1_0_2u 8 | ARG STUNNEL_VERSION=5.75 9 | 10 | ARG OPENSSL_URL=https://github.com/openssl/openssl/releases/download/${OPENSSL_TAG}/openssl-${OPENSSL_VERSION}.tar.gz 11 | ARG STUNNEL_URL=https://www.stunnel.org/downloads/stunnel-${STUNNEL_VERSION}.tar.gz 12 | 13 | # Build prerequisites 14 | RUN apt-get update && \ 15 | apt-get install -y --no-install-recommends \ 16 | build-essential \ 17 | ca-certificates \ 18 | wget \ 19 | perl \ 20 | zlib1g-dev \ 21 | pkg-config && \ 22 | rm -rf /var/lib/apt/lists/* 23 | 24 | WORKDIR /usr/src 25 | 26 | # ---------- OpenSSL ---------------------------------------------------------- 27 | RUN wget -qO openssl.tar.gz "${OPENSSL_URL}" && \ 28 | tar xzf openssl.tar.gz && \ 29 | cd openssl-${OPENSSL_VERSION} && \ 30 | ./config --prefix=/usr/local/openssl --openssldir=/usr/local/openssl shared zlib && \ 31 | make -j"$(nproc)" && \ 32 | make install_sw 33 | 34 | # ---------- stunnel ---------------------------------------------------------- 35 | RUN wget -qO stunnel.tar.gz "${STUNNEL_URL}" && \ 36 | tar xzf stunnel.tar.gz && \ 37 | cd stunnel-${STUNNEL_VERSION} && \ 38 | ./configure \ 39 | --with-ssl=/usr/local/openssl \ 40 | --prefix=/usr/local \ 41 | --sysconfdir=/etc \ 42 | --disable-libwrap && \ 43 | make -j"$(nproc)" && \ 44 | make install 45 | 46 | ############################################################################### 47 | # Runtime stage – only what we need to run stunnel 48 | ############################################################################### 49 | FROM debian:bookworm-slim AS runtime 50 | 51 | COPY --from=build /usr/local/openssl /usr/local/openssl 52 | COPY --from=build /usr/local/bin/stunnel /usr/local/bin/ 53 | COPY --from=build /usr/local/lib /usr/local/lib 54 | 55 | # Make sure the custom OpenSSL is preferred at runtime 56 | ENV LD_LIBRARY_PATH="/usr/local/openssl/lib" 57 | 58 | # Directory to hold the user‑supplied stunnel.conf 59 | RUN mkdir -p /etc/stunnel 60 | 61 | WORKDIR /etc/stunnel 62 | EXPOSE 443 1088 63 | 64 | ENTRYPOINT ["stunnel"] 65 | # You can pass the config file name as CMD or at `docker run` time, e.g.: 66 | # CMD ["stunnel.conf"] -------------------------------------------------------------------------------- /docs/LINUX.md: -------------------------------------------------------------------------------- 1 | # Open OSCAR Server Quickstart for Linux (x86_64) 2 | 3 | This guide explains how to download, configure and run Open OSCAR Server on Linux (x86_64). 4 | 5 | 1. **Download Open OSCAR Server** 6 | 7 | Grab the latest Linux release from the [releases page](https://github.com/mk6i/open-oscar-server/releases) and extract 8 | the archive. The extracted folder contains the application and a configuration file `settings.env`. 9 | 10 | 2. **Configure Server Address** 11 | 12 | Set the default listener in `OSCAR_ADVERTISED_LISTENERS_PLAIN` in `settings.env` to a hostname and port that the AIM 13 | clients can connect to. If you are running the AIM client and server on the same machine, you don't need to change 14 | the default value. 15 | 16 | The format is `[NAME]://[HOSTNAME]:[PORT]` where: 17 | - `LOCAL` is the listener name (can be any name you choose, as long as it matches the `OSCAR_LISTENERS` config) 18 | - `127.0.0.1` is the hostname clients connect to 19 | - `5190` is the port number clients connect to 20 | 21 | In order to connect AIM clients on your LAN (including VMs with bridged networking), you can find the appropriate IP 22 | address by running `ifconfig` from the terminal and use that IP instead of `127.0.0.1`. 23 | 24 | 3. **Start the Application** 25 | 26 | Run the following command to launch Open OSCAR Server: 27 | 28 | ```shell 29 | ./open_oscar_server 30 | ``` 31 | 32 | Open OSCAR Server will run in the terminal, ready to accept AIM client connections. 33 | 34 | 4. **Configure AIM Clients** 35 | 36 | To do a quick sanity check, start an AIM client, sign in to the server, and send yourself an instant message. 37 | Configure the AIM client to connect to the host and port from `OSCAR_ADVERTISED_LISTENERS_PLAIN` in `settings.env`. If 38 | using the default server setting, set host to `127.0.0.1` and port `5190`. 39 | 40 | See the [Client Configuration Guide](./CLIENT.md) for more detail on setting up the AIM client. 41 | 42 | By default, you can enter *any* screen name and password at the AIM sign-in screen to auto-create an account. 43 | 44 | > Account auto-creation is meant to be a convenience feature for local development. In a production deployment, you 45 | should set `DISABLE_AUTH=false` in `settings.env` to enforce account authentication. User accounts can be created via 46 | the [Management API](../README.md#-management-api). 47 | 48 | 5. **Additional Setup** 49 | 50 | For optional configuration steps that enhance your Open OSCAR Server experience, refer to 51 | the [Additional Setup Guide](./ADDITIONAL_SETUP.md). -------------------------------------------------------------------------------- /state/migrations/0019_api_analytics.up.sql: -------------------------------------------------------------------------------- 1 | -- Migration: 0018_api_analytics 2 | -- Description: Create tables for Web API usage analytics and tracking 3 | -- Date: 2024-12-28 4 | 5 | -- Create API usage logs table for detailed request tracking 6 | CREATE TABLE IF NOT EXISTS api_usage_logs ( 7 | id INTEGER PRIMARY KEY AUTOINCREMENT, 8 | dev_id VARCHAR(255) NOT NULL, 9 | endpoint VARCHAR(255) NOT NULL, 10 | method VARCHAR(10) NOT NULL, 11 | timestamp INTEGER NOT NULL, 12 | response_time_ms INTEGER, 13 | status_code INTEGER, 14 | ip_address VARCHAR(45), 15 | user_agent TEXT, 16 | screen_name VARCHAR(16), -- User making the request (if authenticated) 17 | error_message TEXT, -- Store error details if request failed 18 | request_size INTEGER, -- Size of request in bytes 19 | response_size INTEGER -- Size of response in bytes 20 | ); 21 | 22 | -- Create indexes for efficient querying 23 | CREATE INDEX idx_usage_dev_id ON api_usage_logs(dev_id); 24 | CREATE INDEX idx_usage_timestamp ON api_usage_logs(timestamp); 25 | CREATE INDEX idx_usage_endpoint ON api_usage_logs(endpoint); 26 | CREATE INDEX idx_usage_status ON api_usage_logs(status_code); 27 | CREATE INDEX idx_usage_screen_name ON api_usage_logs(screen_name); 28 | 29 | -- Create aggregated statistics table for performance 30 | CREATE TABLE IF NOT EXISTS api_usage_stats ( 31 | id INTEGER PRIMARY KEY AUTOINCREMENT, 32 | dev_id VARCHAR(255) NOT NULL, 33 | endpoint VARCHAR(255) NOT NULL, 34 | period_type VARCHAR(10) NOT NULL, -- 'hour', 'day', 'month' 35 | period_start INTEGER NOT NULL, 36 | request_count INTEGER DEFAULT 0, 37 | error_count INTEGER DEFAULT 0, 38 | total_response_time_ms INTEGER DEFAULT 0, 39 | avg_response_time_ms INTEGER DEFAULT 0, 40 | total_request_bytes INTEGER DEFAULT 0, 41 | total_response_bytes INTEGER DEFAULT 0, 42 | unique_users INTEGER DEFAULT 0, 43 | UNIQUE(dev_id, endpoint, period_type, period_start) 44 | ); 45 | 46 | -- Create indexes for aggregated stats 47 | CREATE INDEX idx_stats_dev_id ON api_usage_stats(dev_id); 48 | CREATE INDEX idx_stats_period ON api_usage_stats(period_type, period_start); 49 | CREATE INDEX idx_stats_endpoint ON api_usage_stats(endpoint); 50 | 51 | -- Create table for tracking API key quotas and limits 52 | CREATE TABLE IF NOT EXISTS api_quotas ( 53 | dev_id VARCHAR(255) PRIMARY KEY, 54 | daily_limit INTEGER DEFAULT 10000, 55 | monthly_limit INTEGER DEFAULT 300000, 56 | daily_used INTEGER DEFAULT 0, 57 | monthly_used INTEGER DEFAULT 0, 58 | last_reset_daily INTEGER NOT NULL, 59 | last_reset_monthly INTEGER NOT NULL, 60 | overage_allowed BOOLEAN DEFAULT FALSE 61 | ); 62 | -------------------------------------------------------------------------------- /docs/CLIENT.md: -------------------------------------------------------------------------------- 1 | # Windows AIM 5.x Client Setup 2 | 3 | This guide explains how to install and configure Windows AIM 5.x clients for Open OSCAR Server. 4 | 5 | AIM 5.x is recommended if you want to experience the last version of AIM with the "classic" early-2000s feel. 6 | 7 | ## Installation 8 | 9 | ### Linux / FreeBSD 10 | 11 | Windows AIM versions 5.0-5.1.3036 run perfectly well on [Wine](https://www.winehq.org/). Here's how to set up AIM 12 | 5.1.3036. 13 | 14 | 1. [Install Wine](https://wiki.winehq.org/Download) 15 | 2. Download the AIM installer from [archive.org](https://archive.org/details/aim513036) 16 | 3. Run the installer from the terminal: 17 | ```shell 18 | wine aim513036.exe 19 | ``` 20 | 21 | ### macOS (Intel & Apple Silicon) 22 | 23 | Windows AIM can run on modern macOS without a VM, including the Apple Silicon platform! 24 | 25 | Get started with the [AIM for macOS project](https://github.com/mk6i/aim-for-macos). 26 | 27 | ### Windows 10/11 28 | 29 | 1. Download AIM 5.9.6089 (available on [NINA wiki](https://web.archive.org/web/20250910233232/https://wiki.nina.chat/wiki/Clients/AOL_Instant_Messenger#Windows)). 30 | 2. Install AIM and exit out of the application post-installation. 31 | 3. Open **Task Manager** and kill the **AOL Instant Messenger (32 bit)** process to make sure the application is 32 | actually terminated. 33 | 4. Open **File Explorer** and navigate to `C:\Program Files (x86)\AIM`. 34 | 5. Delete `aimapi.dll`. 35 | 6. Set [Windows XP compatibility mode](https://support.microsoft.com/en-us/windows/make-older-apps-or-programs-compatible-with-windows-783d6dd7-b439-bdb0-0490-54eea0f45938) 36 | on `aim.exe`. 37 | 38 | 7. Launch AIM. 39 | 40 | ## Configuration 41 | 42 | Once installed, configure AIM to connect to Open OSCAR Server. 43 | 44 | 1. At the sign-on screen, click `Setup`. 45 |
46 |
47 |
50 |
51 |
56 |
57 |
35 |
36 |
37 |
42 |
43 |
hello world!
"), 117 | }, 118 | } 119 | b := &bytes.Buffer{} 120 | err := MarshalBE(tlv, b) 121 | assert.NoError(t, err) 122 | return b.Bytes() 123 | }(), 124 | want: "hello world!
", 125 | }, 126 | { 127 | name: "missing ChatTLVMessageInfoText", 128 | b: func() []byte { 129 | tlv := TLVRestBlock{TLVList: TLVList{}} 130 | b := &bytes.Buffer{} 131 | err := MarshalBE(tlv, b) 132 | assert.NoError(t, err) 133 | return b.Bytes() 134 | }(), 135 | wantErr: "has no chat msg text TLV", 136 | }, 137 | } 138 | for _, tt := range tests { 139 | t.Run(tt.name, func(t *testing.T) { 140 | got, err := UnmarshalChatMessageText(tt.b) 141 | if tt.wantErr != "" { 142 | assert.ErrorContains(t, err, tt.wantErr) 143 | } else { 144 | assert.NoError(t, err) 145 | assert.Equal(t, tt.want, got) 146 | } 147 | }) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /server/oscar/mock_stats_service_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package oscar 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/mk6i/retro-aim-server/wire" 11 | mock "github.com/stretchr/testify/mock" 12 | ) 13 | 14 | // newMockStatsService creates a new instance of mockStatsService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 15 | // The first argument is typically a *testing.T value. 16 | func newMockStatsService(t interface { 17 | mock.TestingT 18 | Cleanup(func()) 19 | }) *mockStatsService { 20 | mock := &mockStatsService{} 21 | mock.Mock.Test(t) 22 | 23 | t.Cleanup(func() { mock.AssertExpectations(t) }) 24 | 25 | return mock 26 | } 27 | 28 | // mockStatsService is an autogenerated mock type for the StatsService type 29 | type mockStatsService struct { 30 | mock.Mock 31 | } 32 | 33 | type mockStatsService_Expecter struct { 34 | mock *mock.Mock 35 | } 36 | 37 | func (_m *mockStatsService) EXPECT() *mockStatsService_Expecter { 38 | return &mockStatsService_Expecter{mock: &_m.Mock} 39 | } 40 | 41 | // ReportEvents provides a mock function for the type mockStatsService 42 | func (_mock *mockStatsService) ReportEvents(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0B_0x03_StatsReportEvents) wire.SNACMessage { 43 | ret := _mock.Called(ctx, inFrame, inBody) 44 | 45 | if len(ret) == 0 { 46 | panic("no return value specified for ReportEvents") 47 | } 48 | 49 | var r0 wire.SNACMessage 50 | if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0B_0x03_StatsReportEvents) wire.SNACMessage); ok { 51 | r0 = returnFunc(ctx, inFrame, inBody) 52 | } else { 53 | r0 = ret.Get(0).(wire.SNACMessage) 54 | } 55 | return r0 56 | } 57 | 58 | // mockStatsService_ReportEvents_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReportEvents' 59 | type mockStatsService_ReportEvents_Call struct { 60 | *mock.Call 61 | } 62 | 63 | // ReportEvents is a helper method to define mock.On call 64 | // - ctx context.Context 65 | // - inFrame wire.SNACFrame 66 | // - inBody wire.SNAC_0x0B_0x03_StatsReportEvents 67 | func (_e *mockStatsService_Expecter) ReportEvents(ctx interface{}, inFrame interface{}, inBody interface{}) *mockStatsService_ReportEvents_Call { 68 | return &mockStatsService_ReportEvents_Call{Call: _e.mock.On("ReportEvents", ctx, inFrame, inBody)} 69 | } 70 | 71 | func (_c *mockStatsService_ReportEvents_Call) Run(run func(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0B_0x03_StatsReportEvents)) *mockStatsService_ReportEvents_Call { 72 | _c.Call.Run(func(args mock.Arguments) { 73 | var arg0 context.Context 74 | if args[0] != nil { 75 | arg0 = args[0].(context.Context) 76 | } 77 | var arg1 wire.SNACFrame 78 | if args[1] != nil { 79 | arg1 = args[1].(wire.SNACFrame) 80 | } 81 | var arg2 wire.SNAC_0x0B_0x03_StatsReportEvents 82 | if args[2] != nil { 83 | arg2 = args[2].(wire.SNAC_0x0B_0x03_StatsReportEvents) 84 | } 85 | run( 86 | arg0, 87 | arg1, 88 | arg2, 89 | ) 90 | }) 91 | return _c 92 | } 93 | 94 | func (_c *mockStatsService_ReportEvents_Call) Return(sNACMessage wire.SNACMessage) *mockStatsService_ReportEvents_Call { 95 | _c.Call.Return(sNACMessage) 96 | return _c 97 | } 98 | 99 | func (_c *mockStatsService_ReportEvents_Call) RunAndReturn(run func(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0B_0x03_StatsReportEvents) wire.SNACMessage) *mockStatsService_ReportEvents_Call { 100 | _c.Call.Return(run) 101 | return _c 102 | } 103 | -------------------------------------------------------------------------------- /server/oscar/mock_rate_limit_updater_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package oscar 6 | 7 | import ( 8 | "context" 9 | "time" 10 | 11 | "github.com/mk6i/retro-aim-server/state" 12 | "github.com/mk6i/retro-aim-server/wire" 13 | mock "github.com/stretchr/testify/mock" 14 | ) 15 | 16 | // newMockRateLimitUpdater creates a new instance of mockRateLimitUpdater. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 17 | // The first argument is typically a *testing.T value. 18 | func newMockRateLimitUpdater(t interface { 19 | mock.TestingT 20 | Cleanup(func()) 21 | }) *mockRateLimitUpdater { 22 | mock := &mockRateLimitUpdater{} 23 | mock.Mock.Test(t) 24 | 25 | t.Cleanup(func() { mock.AssertExpectations(t) }) 26 | 27 | return mock 28 | } 29 | 30 | // mockRateLimitUpdater is an autogenerated mock type for the RateLimitUpdater type 31 | type mockRateLimitUpdater struct { 32 | mock.Mock 33 | } 34 | 35 | type mockRateLimitUpdater_Expecter struct { 36 | mock *mock.Mock 37 | } 38 | 39 | func (_m *mockRateLimitUpdater) EXPECT() *mockRateLimitUpdater_Expecter { 40 | return &mockRateLimitUpdater_Expecter{mock: &_m.Mock} 41 | } 42 | 43 | // RateLimitUpdates provides a mock function for the type mockRateLimitUpdater 44 | func (_mock *mockRateLimitUpdater) RateLimitUpdates(ctx context.Context, sess *state.Session, now time.Time) []wire.SNACMessage { 45 | ret := _mock.Called(ctx, sess, now) 46 | 47 | if len(ret) == 0 { 48 | panic("no return value specified for RateLimitUpdates") 49 | } 50 | 51 | var r0 []wire.SNACMessage 52 | if returnFunc, ok := ret.Get(0).(func(context.Context, *state.Session, time.Time) []wire.SNACMessage); ok { 53 | r0 = returnFunc(ctx, sess, now) 54 | } else { 55 | if ret.Get(0) != nil { 56 | r0 = ret.Get(0).([]wire.SNACMessage) 57 | } 58 | } 59 | return r0 60 | } 61 | 62 | // mockRateLimitUpdater_RateLimitUpdates_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RateLimitUpdates' 63 | type mockRateLimitUpdater_RateLimitUpdates_Call struct { 64 | *mock.Call 65 | } 66 | 67 | // RateLimitUpdates is a helper method to define mock.On call 68 | // - ctx context.Context 69 | // - sess *state.Session 70 | // - now time.Time 71 | func (_e *mockRateLimitUpdater_Expecter) RateLimitUpdates(ctx interface{}, sess interface{}, now interface{}) *mockRateLimitUpdater_RateLimitUpdates_Call { 72 | return &mockRateLimitUpdater_RateLimitUpdates_Call{Call: _e.mock.On("RateLimitUpdates", ctx, sess, now)} 73 | } 74 | 75 | func (_c *mockRateLimitUpdater_RateLimitUpdates_Call) Run(run func(ctx context.Context, sess *state.Session, now time.Time)) *mockRateLimitUpdater_RateLimitUpdates_Call { 76 | _c.Call.Run(func(args mock.Arguments) { 77 | var arg0 context.Context 78 | if args[0] != nil { 79 | arg0 = args[0].(context.Context) 80 | } 81 | var arg1 *state.Session 82 | if args[1] != nil { 83 | arg1 = args[1].(*state.Session) 84 | } 85 | var arg2 time.Time 86 | if args[2] != nil { 87 | arg2 = args[2].(time.Time) 88 | } 89 | run( 90 | arg0, 91 | arg1, 92 | arg2, 93 | ) 94 | }) 95 | return _c 96 | } 97 | 98 | func (_c *mockRateLimitUpdater_RateLimitUpdates_Call) Return(sNACMessages []wire.SNACMessage) *mockRateLimitUpdater_RateLimitUpdates_Call { 99 | _c.Call.Return(sNACMessages) 100 | return _c 101 | } 102 | 103 | func (_c *mockRateLimitUpdater_RateLimitUpdates_Call) RunAndReturn(run func(ctx context.Context, sess *state.Session, now time.Time) []wire.SNACMessage) *mockRateLimitUpdater_RateLimitUpdates_Call { 104 | _c.Call.Return(run) 105 | return _c 106 | } 107 | -------------------------------------------------------------------------------- /server/http/mock_chat_room_retriever_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package http 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/mk6i/retro-aim-server/state" 11 | mock "github.com/stretchr/testify/mock" 12 | ) 13 | 14 | // newMockChatRoomRetriever creates a new instance of mockChatRoomRetriever. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 15 | // The first argument is typically a *testing.T value. 16 | func newMockChatRoomRetriever(t interface { 17 | mock.TestingT 18 | Cleanup(func()) 19 | }) *mockChatRoomRetriever { 20 | mock := &mockChatRoomRetriever{} 21 | mock.Mock.Test(t) 22 | 23 | t.Cleanup(func() { mock.AssertExpectations(t) }) 24 | 25 | return mock 26 | } 27 | 28 | // mockChatRoomRetriever is an autogenerated mock type for the ChatRoomRetriever type 29 | type mockChatRoomRetriever struct { 30 | mock.Mock 31 | } 32 | 33 | type mockChatRoomRetriever_Expecter struct { 34 | mock *mock.Mock 35 | } 36 | 37 | func (_m *mockChatRoomRetriever) EXPECT() *mockChatRoomRetriever_Expecter { 38 | return &mockChatRoomRetriever_Expecter{mock: &_m.Mock} 39 | } 40 | 41 | // AllChatRooms provides a mock function for the type mockChatRoomRetriever 42 | func (_mock *mockChatRoomRetriever) AllChatRooms(ctx context.Context, exchange uint16) ([]state.ChatRoom, error) { 43 | ret := _mock.Called(ctx, exchange) 44 | 45 | if len(ret) == 0 { 46 | panic("no return value specified for AllChatRooms") 47 | } 48 | 49 | var r0 []state.ChatRoom 50 | var r1 error 51 | if returnFunc, ok := ret.Get(0).(func(context.Context, uint16) ([]state.ChatRoom, error)); ok { 52 | return returnFunc(ctx, exchange) 53 | } 54 | if returnFunc, ok := ret.Get(0).(func(context.Context, uint16) []state.ChatRoom); ok { 55 | r0 = returnFunc(ctx, exchange) 56 | } else { 57 | if ret.Get(0) != nil { 58 | r0 = ret.Get(0).([]state.ChatRoom) 59 | } 60 | } 61 | if returnFunc, ok := ret.Get(1).(func(context.Context, uint16) error); ok { 62 | r1 = returnFunc(ctx, exchange) 63 | } else { 64 | r1 = ret.Error(1) 65 | } 66 | return r0, r1 67 | } 68 | 69 | // mockChatRoomRetriever_AllChatRooms_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AllChatRooms' 70 | type mockChatRoomRetriever_AllChatRooms_Call struct { 71 | *mock.Call 72 | } 73 | 74 | // AllChatRooms is a helper method to define mock.On call 75 | // - ctx context.Context 76 | // - exchange uint16 77 | func (_e *mockChatRoomRetriever_Expecter) AllChatRooms(ctx interface{}, exchange interface{}) *mockChatRoomRetriever_AllChatRooms_Call { 78 | return &mockChatRoomRetriever_AllChatRooms_Call{Call: _e.mock.On("AllChatRooms", ctx, exchange)} 79 | } 80 | 81 | func (_c *mockChatRoomRetriever_AllChatRooms_Call) Run(run func(ctx context.Context, exchange uint16)) *mockChatRoomRetriever_AllChatRooms_Call { 82 | _c.Call.Run(func(args mock.Arguments) { 83 | var arg0 context.Context 84 | if args[0] != nil { 85 | arg0 = args[0].(context.Context) 86 | } 87 | var arg1 uint16 88 | if args[1] != nil { 89 | arg1 = args[1].(uint16) 90 | } 91 | run( 92 | arg0, 93 | arg1, 94 | ) 95 | }) 96 | return _c 97 | } 98 | 99 | func (_c *mockChatRoomRetriever_AllChatRooms_Call) Return(chatRooms []state.ChatRoom, err error) *mockChatRoomRetriever_AllChatRooms_Call { 100 | _c.Call.Return(chatRooms, err) 101 | return _c 102 | } 103 | 104 | func (_c *mockChatRoomRetriever_AllChatRooms_Call) RunAndReturn(run func(ctx context.Context, exchange uint16) ([]state.ChatRoom, error)) *mockChatRoomRetriever_AllChatRooms_Call { 105 | _c.Call.Return(run) 106 | return _c 107 | } 108 | -------------------------------------------------------------------------------- /server/http/mock_profile_retriever_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package http 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/mk6i/retro-aim-server/state" 11 | mock "github.com/stretchr/testify/mock" 12 | ) 13 | 14 | // newMockProfileRetriever creates a new instance of mockProfileRetriever. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 15 | // The first argument is typically a *testing.T value. 16 | func newMockProfileRetriever(t interface { 17 | mock.TestingT 18 | Cleanup(func()) 19 | }) *mockProfileRetriever { 20 | mock := &mockProfileRetriever{} 21 | mock.Mock.Test(t) 22 | 23 | t.Cleanup(func() { mock.AssertExpectations(t) }) 24 | 25 | return mock 26 | } 27 | 28 | // mockProfileRetriever is an autogenerated mock type for the ProfileRetriever type 29 | type mockProfileRetriever struct { 30 | mock.Mock 31 | } 32 | 33 | type mockProfileRetriever_Expecter struct { 34 | mock *mock.Mock 35 | } 36 | 37 | func (_m *mockProfileRetriever) EXPECT() *mockProfileRetriever_Expecter { 38 | return &mockProfileRetriever_Expecter{mock: &_m.Mock} 39 | } 40 | 41 | // Profile provides a mock function for the type mockProfileRetriever 42 | func (_mock *mockProfileRetriever) Profile(ctx context.Context, screenName state.IdentScreenName) (state.UserProfile, error) { 43 | ret := _mock.Called(ctx, screenName) 44 | 45 | if len(ret) == 0 { 46 | panic("no return value specified for Profile") 47 | } 48 | 49 | var r0 state.UserProfile 50 | var r1 error 51 | if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) (state.UserProfile, error)); ok { 52 | return returnFunc(ctx, screenName) 53 | } 54 | if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) state.UserProfile); ok { 55 | r0 = returnFunc(ctx, screenName) 56 | } else { 57 | r0 = ret.Get(0).(state.UserProfile) 58 | } 59 | if returnFunc, ok := ret.Get(1).(func(context.Context, state.IdentScreenName) error); ok { 60 | r1 = returnFunc(ctx, screenName) 61 | } else { 62 | r1 = ret.Error(1) 63 | } 64 | return r0, r1 65 | } 66 | 67 | // mockProfileRetriever_Profile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Profile' 68 | type mockProfileRetriever_Profile_Call struct { 69 | *mock.Call 70 | } 71 | 72 | // Profile is a helper method to define mock.On call 73 | // - ctx context.Context 74 | // - screenName state.IdentScreenName 75 | func (_e *mockProfileRetriever_Expecter) Profile(ctx interface{}, screenName interface{}) *mockProfileRetriever_Profile_Call { 76 | return &mockProfileRetriever_Profile_Call{Call: _e.mock.On("Profile", ctx, screenName)} 77 | } 78 | 79 | func (_c *mockProfileRetriever_Profile_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName)) *mockProfileRetriever_Profile_Call { 80 | _c.Call.Run(func(args mock.Arguments) { 81 | var arg0 context.Context 82 | if args[0] != nil { 83 | arg0 = args[0].(context.Context) 84 | } 85 | var arg1 state.IdentScreenName 86 | if args[1] != nil { 87 | arg1 = args[1].(state.IdentScreenName) 88 | } 89 | run( 90 | arg0, 91 | arg1, 92 | ) 93 | }) 94 | return _c 95 | } 96 | 97 | func (_c *mockProfileRetriever_Profile_Call) Return(userProfile state.UserProfile, err error) *mockProfileRetriever_Profile_Call { 98 | _c.Call.Return(userProfile, err) 99 | return _c 100 | } 101 | 102 | func (_c *mockProfileRetriever_Profile_Call) RunAndReturn(run func(ctx context.Context, screenName state.IdentScreenName) (state.UserProfile, error)) *mockProfileRetriever_Profile_Call { 103 | _c.Call.Return(run) 104 | return _c 105 | } 106 | -------------------------------------------------------------------------------- /state/cookie.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import ( 4 | "bytes" 5 | "crypto/hmac" 6 | "crypto/rand" 7 | "crypto/sha256" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "time" 12 | 13 | "github.com/mk6i/retro-aim-server/wire" 14 | ) 15 | 16 | // authCookieLen is the fixed auth cookie length. 17 | const authCookieLen = 256 18 | 19 | // ServerCookie represents a token containing client metadata passed to the BOS 20 | // service upon connection. 21 | type ServerCookie struct { 22 | Service uint16 23 | ScreenName DisplayScreenName `oscar:"len_prefix=uint8"` 24 | ClientID string `oscar:"len_prefix=uint8"` 25 | ChatCookie string `oscar:"len_prefix=uint8"` 26 | MultiConnFlag uint8 27 | // KerberosAuth indicates whether the client used Kerberos for authentication. 28 | KerberosAuth uint8 29 | } 30 | 31 | func NewHMACCookieBaker() (HMACCookieBaker, error) { 32 | cb := HMACCookieBaker{} 33 | cb.key = make([]byte, 32) 34 | if _, err := io.ReadFull(rand.Reader, cb.key); err != nil { 35 | return cb, fmt.Errorf("cannot generate random HMAC key: %w", err) 36 | } 37 | return cb, nil 38 | } 39 | 40 | type HMACCookieBaker struct { 41 | key []byte 42 | } 43 | 44 | func (c HMACCookieBaker) Issue(data []byte) ([]byte, error) { 45 | payload := hmacTokenPayload{ 46 | Expiry: uint32(time.Now().Add(1 * time.Minute).Unix()), 47 | Data: data, 48 | } 49 | buf := &bytes.Buffer{} 50 | if err := wire.MarshalBE(payload, buf); err != nil { 51 | return nil, fmt.Errorf("unable to marshal auth authCookie: %w", err) 52 | } 53 | 54 | hmacTok := hmacToken{ 55 | Data: buf.Bytes(), 56 | } 57 | hmacTok.hash(c.key) 58 | 59 | buf.Reset() 60 | 61 | if err := wire.MarshalBE(hmacTok, buf); err != nil { 62 | return nil, fmt.Errorf("unable to marshal auth authCookie: %w", err) 63 | } 64 | 65 | // Some clients (such as perl NET::OSCAR) expect the auth cookie to be 66 | // exactly 256 bytes, even though the cookie is stored in a 67 | // variable-length TLV. Pad the auth cookie to make sure it's exactly 68 | // 256 bytes. 69 | if buf.Len() > authCookieLen { 70 | return nil, fmt.Errorf("sess is too long, expect 256 bytes, got %d", buf.Len()) 71 | } 72 | buf.Write(make([]byte, authCookieLen-buf.Len())) 73 | 74 | return buf.Bytes(), nil 75 | } 76 | 77 | func (c HMACCookieBaker) Crack(data []byte) ([]byte, error) { 78 | hmacTok := hmacToken{} 79 | if err := wire.UnmarshalBE(&hmacTok, bytes.NewBuffer(data)); err != nil { 80 | return nil, fmt.Errorf("unable to unmarshal HMAC cooie: %w", err) 81 | } 82 | 83 | if !hmacTok.validate(c.key) { 84 | return nil, errors.New("invalid HMAC cookie") 85 | } 86 | 87 | payload := hmacTokenPayload{} 88 | if err := wire.UnmarshalBE(&payload, bytes.NewBuffer(hmacTok.Data)); err != nil { 89 | return nil, fmt.Errorf("unable to unmarshal HMAC cookie payload: %w", err) 90 | } 91 | 92 | expiry := time.Unix(int64(payload.Expiry), 0) 93 | if expiry.Before(time.Now()) { 94 | return nil, errors.New("HMAC cookie expired") 95 | } 96 | 97 | return payload.Data, nil 98 | } 99 | 100 | type hmacTokenPayload struct { 101 | Expiry uint32 102 | Data []byte `oscar:"len_prefix=uint16"` 103 | } 104 | 105 | type hmacToken struct { 106 | Data []byte `oscar:"len_prefix=uint16"` 107 | Sig []byte `oscar:"len_prefix=uint16"` 108 | } 109 | 110 | func (h *hmacToken) hash(key []byte) { 111 | hs := hmac.New(sha256.New, key) 112 | if _, err := hs.Write(h.Data); err != nil { 113 | // according to Hash interface, Write() should never return an error 114 | panic("unable to compute hmac token") 115 | } 116 | h.Sig = hs.Sum(nil) 117 | } 118 | 119 | func (h *hmacToken) validate(key []byte) bool { 120 | cp := *h 121 | cp.hash(key) 122 | return hmac.Equal(h.Sig, cp.Sig) 123 | } 124 | -------------------------------------------------------------------------------- /server/http/mock_feedbag_retriever_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package http 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/mk6i/retro-aim-server/state" 11 | "github.com/mk6i/retro-aim-server/wire" 12 | mock "github.com/stretchr/testify/mock" 13 | ) 14 | 15 | // newMockFeedBagRetriever creates a new instance of mockFeedBagRetriever. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 16 | // The first argument is typically a *testing.T value. 17 | func newMockFeedBagRetriever(t interface { 18 | mock.TestingT 19 | Cleanup(func()) 20 | }) *mockFeedBagRetriever { 21 | mock := &mockFeedBagRetriever{} 22 | mock.Mock.Test(t) 23 | 24 | t.Cleanup(func() { mock.AssertExpectations(t) }) 25 | 26 | return mock 27 | } 28 | 29 | // mockFeedBagRetriever is an autogenerated mock type for the FeedBagRetriever type 30 | type mockFeedBagRetriever struct { 31 | mock.Mock 32 | } 33 | 34 | type mockFeedBagRetriever_Expecter struct { 35 | mock *mock.Mock 36 | } 37 | 38 | func (_m *mockFeedBagRetriever) EXPECT() *mockFeedBagRetriever_Expecter { 39 | return &mockFeedBagRetriever_Expecter{mock: &_m.Mock} 40 | } 41 | 42 | // BuddyIconMetadata provides a mock function for the type mockFeedBagRetriever 43 | func (_mock *mockFeedBagRetriever) BuddyIconMetadata(ctx context.Context, screenName state.IdentScreenName) (*wire.BARTID, error) { 44 | ret := _mock.Called(ctx, screenName) 45 | 46 | if len(ret) == 0 { 47 | panic("no return value specified for BuddyIconMetadata") 48 | } 49 | 50 | var r0 *wire.BARTID 51 | var r1 error 52 | if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) (*wire.BARTID, error)); ok { 53 | return returnFunc(ctx, screenName) 54 | } 55 | if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) *wire.BARTID); ok { 56 | r0 = returnFunc(ctx, screenName) 57 | } else { 58 | if ret.Get(0) != nil { 59 | r0 = ret.Get(0).(*wire.BARTID) 60 | } 61 | } 62 | if returnFunc, ok := ret.Get(1).(func(context.Context, state.IdentScreenName) error); ok { 63 | r1 = returnFunc(ctx, screenName) 64 | } else { 65 | r1 = ret.Error(1) 66 | } 67 | return r0, r1 68 | } 69 | 70 | // mockFeedBagRetriever_BuddyIconMetadata_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BuddyIconMetadata' 71 | type mockFeedBagRetriever_BuddyIconMetadata_Call struct { 72 | *mock.Call 73 | } 74 | 75 | // BuddyIconMetadata is a helper method to define mock.On call 76 | // - ctx context.Context 77 | // - screenName state.IdentScreenName 78 | func (_e *mockFeedBagRetriever_Expecter) BuddyIconMetadata(ctx interface{}, screenName interface{}) *mockFeedBagRetriever_BuddyIconMetadata_Call { 79 | return &mockFeedBagRetriever_BuddyIconMetadata_Call{Call: _e.mock.On("BuddyIconMetadata", ctx, screenName)} 80 | } 81 | 82 | func (_c *mockFeedBagRetriever_BuddyIconMetadata_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName)) *mockFeedBagRetriever_BuddyIconMetadata_Call { 83 | _c.Call.Run(func(args mock.Arguments) { 84 | var arg0 context.Context 85 | if args[0] != nil { 86 | arg0 = args[0].(context.Context) 87 | } 88 | var arg1 state.IdentScreenName 89 | if args[1] != nil { 90 | arg1 = args[1].(state.IdentScreenName) 91 | } 92 | run( 93 | arg0, 94 | arg1, 95 | ) 96 | }) 97 | return _c 98 | } 99 | 100 | func (_c *mockFeedBagRetriever_BuddyIconMetadata_Call) Return(bARTID *wire.BARTID, err error) *mockFeedBagRetriever_BuddyIconMetadata_Call { 101 | _c.Call.Return(bARTID, err) 102 | return _c 103 | } 104 | 105 | func (_c *mockFeedBagRetriever_BuddyIconMetadata_Call) RunAndReturn(run func(ctx context.Context, screenName state.IdentScreenName) (*wire.BARTID, error)) *mockFeedBagRetriever_BuddyIconMetadata_Call { 106 | _c.Call.Return(run) 107 | return _c 108 | } 109 | -------------------------------------------------------------------------------- /foodgroup/user_lookup_test.go: -------------------------------------------------------------------------------- 1 | package foodgroup 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "testing" 7 | 8 | "github.com/mk6i/retro-aim-server/state" 9 | "github.com/mk6i/retro-aim-server/wire" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestUserLookupService_FindByEmail(t *testing.T) { 15 | cases := []struct { 16 | // name is the unit test name 17 | name string 18 | // inputSNAC is the SNAC sent by the sender client 19 | inputSNAC wire.SNACMessage 20 | // expectSNACFrame is the SNAC frame sent from the server to the recipient 21 | // client 22 | expectOutput wire.SNACMessage 23 | // mockParams is the list of params sent to mocks that satisfy this 24 | // method's dependencies 25 | mockParams mockParams 26 | // expectErr is the expected error returned by the handler 27 | expectErr error 28 | }{ 29 | { 30 | name: "search by email address - results found", 31 | inputSNAC: wire.SNACMessage{ 32 | Frame: wire.SNACFrame{ 33 | RequestID: 1234, 34 | }, 35 | Body: wire.SNAC_0x0A_0x02_UserLookupFindByEmail{ 36 | Email: []byte("user@aol.com"), 37 | }, 38 | }, 39 | expectOutput: wire.SNACMessage{ 40 | Frame: wire.SNACFrame{ 41 | FoodGroup: wire.UserLookup, 42 | SubGroup: wire.UserLookupFindReply, 43 | RequestID: 1234, 44 | }, 45 | Body: wire.SNAC_0x0A_0x03_UserLookupFindReply{ 46 | TLVRestBlock: wire.TLVRestBlock{ 47 | TLVList: wire.TLVList{ 48 | wire.NewTLVBE(wire.UserLookupTLVEmailAddress, "ChattingChuck"), 49 | }, 50 | }, 51 | }, 52 | }, 53 | mockParams: mockParams{ 54 | profileManagerParams: profileManagerParams{ 55 | findByAIMEmailParams: findByAIMEmailParams{ 56 | { 57 | email: "user@aol.com", 58 | result: state.User{ 59 | DisplayScreenName: "ChattingChuck", 60 | }, 61 | }, 62 | }, 63 | }, 64 | }, 65 | }, 66 | { 67 | name: "search by email address - no results found", 68 | inputSNAC: wire.SNACMessage{ 69 | Frame: wire.SNACFrame{ 70 | RequestID: 1234, 71 | }, 72 | Body: wire.SNAC_0x0A_0x02_UserLookupFindByEmail{ 73 | Email: []byte("user@aol.com"), 74 | }, 75 | }, 76 | expectOutput: wire.SNACMessage{ 77 | Frame: wire.SNACFrame{ 78 | FoodGroup: wire.UserLookup, 79 | SubGroup: wire.UserLookupErr, 80 | RequestID: 1234, 81 | }, 82 | Body: wire.UserLookupErrNoUserFound, 83 | }, 84 | mockParams: mockParams{ 85 | profileManagerParams: profileManagerParams{ 86 | findByAIMEmailParams: findByAIMEmailParams{ 87 | { 88 | email: "user@aol.com", 89 | err: state.ErrNoUser, 90 | }, 91 | }, 92 | }, 93 | }, 94 | }, 95 | { 96 | name: "search by email address - search error", 97 | inputSNAC: wire.SNACMessage{ 98 | Frame: wire.SNACFrame{ 99 | RequestID: 1234, 100 | }, 101 | Body: wire.SNAC_0x0A_0x02_UserLookupFindByEmail{ 102 | Email: []byte("user@aol.com"), 103 | }, 104 | }, 105 | expectOutput: wire.SNACMessage{}, 106 | expectErr: io.EOF, 107 | mockParams: mockParams{ 108 | profileManagerParams: profileManagerParams{ 109 | findByAIMEmailParams: findByAIMEmailParams{ 110 | { 111 | email: "user@aol.com", 112 | err: io.EOF, 113 | }, 114 | }, 115 | }, 116 | }, 117 | }, 118 | } 119 | 120 | for _, tc := range cases { 121 | t.Run(tc.name, func(t *testing.T) { 122 | profileManager := newMockProfileManager(t) 123 | 124 | for _, params := range tc.mockParams.findByAIMEmailParams { 125 | profileManager.EXPECT(). 126 | FindByAIMEmail(matchContext(), params.email). 127 | Return(params.result, params.err) 128 | } 129 | 130 | svc := NewUserLookupService(profileManager) 131 | actual, err := svc.FindByEmail(context.Background(), tc.inputSNAC.Frame, tc.inputSNAC.Body.(wire.SNAC_0x0A_0x02_UserLookupFindByEmail)) 132 | assert.ErrorIs(t, err, tc.expectErr) 133 | assert.Equal(t, tc.expectOutput, actual) 134 | }) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /state/webapi_auth.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "fmt" 8 | "time" 9 | ) 10 | 11 | // WebAPITokenStore manages authentication tokens for Web API sessions. 12 | type WebAPITokenStore struct { 13 | store *SQLiteUserStore 14 | } 15 | 16 | // NewWebAPITokenStore creates a new token store. 17 | func (s *SQLiteUserStore) NewWebAPITokenStore() *WebAPITokenStore { 18 | return &WebAPITokenStore{store: s} 19 | } 20 | 21 | // StoreToken saves an authentication token for a user. 22 | func (s *WebAPITokenStore) StoreToken(ctx context.Context, token string, screenName IdentScreenName, expiresAt time.Time) error { 23 | query := ` 24 | INSERT INTO webapi_tokens (token, screen_name, expires_at, created_at) 25 | VALUES (?, ?, ?, ?) 26 | ON CONFLICT(token) DO UPDATE SET 27 | screen_name = excluded.screen_name, 28 | expires_at = excluded.expires_at 29 | ` 30 | _, err := s.store.db.ExecContext(ctx, query, token, screenName.String(), expiresAt, time.Now()) 31 | if err != nil { 32 | return fmt.Errorf("failed to store token: %w", err) 33 | } 34 | return nil 35 | } 36 | 37 | // ValidateToken checks if a token is valid and returns the associated screen name. 38 | func (s *WebAPITokenStore) ValidateToken(ctx context.Context, token string) (IdentScreenName, error) { 39 | var screenNameStr string 40 | var expiresAt time.Time 41 | 42 | query := ` 43 | SELECT screen_name, expires_at 44 | FROM webapi_tokens 45 | WHERE token = ? 46 | ` 47 | err := s.store.db.QueryRowContext(ctx, query, token).Scan(&screenNameStr, &expiresAt) 48 | if err != nil { 49 | if errors.Is(err, sql.ErrNoRows) { 50 | return NewIdentScreenName(""), errors.New("invalid token") 51 | } 52 | return NewIdentScreenName(""), fmt.Errorf("failed to validate token: %w", err) 53 | } 54 | 55 | // Check if token has expired 56 | if time.Now().After(expiresAt) { 57 | // Clean up expired token 58 | s.DeleteToken(ctx, token) 59 | return NewIdentScreenName(""), errors.New("token expired") 60 | } 61 | 62 | return NewIdentScreenName(screenNameStr), nil 63 | } 64 | 65 | // DeleteToken removes a token. 66 | func (s *WebAPITokenStore) DeleteToken(ctx context.Context, token string) error { 67 | query := `DELETE FROM webapi_tokens WHERE token = ?` 68 | _, err := s.store.db.ExecContext(ctx, query, token) 69 | if err != nil { 70 | return fmt.Errorf("failed to delete token: %w", err) 71 | } 72 | return nil 73 | } 74 | 75 | // CleanupExpiredTokens removes all expired tokens from the database. 76 | func (s *WebAPITokenStore) CleanupExpiredTokens(ctx context.Context) error { 77 | query := `DELETE FROM webapi_tokens WHERE expires_at < ?` 78 | _, err := s.store.db.ExecContext(ctx, query, time.Now()) 79 | if err != nil { 80 | return fmt.Errorf("failed to cleanup expired tokens: %w", err) 81 | } 82 | return nil 83 | } 84 | 85 | // AuthenticateUser verifies username and password. 86 | // This implementation uses the existing user store for authentication. 87 | func (u *SQLiteUserStore) AuthenticateUser(ctx context.Context, username, password string) (*User, error) { 88 | // Convert username to IdentScreenName for lookup 89 | identSN := NewIdentScreenName(username) 90 | 91 | // Try to find the user 92 | user, err := u.User(ctx, identSN) 93 | if err != nil { 94 | return nil, fmt.Errorf("user not found: %w", err) 95 | } 96 | 97 | // In development mode with DISABLE_AUTH=true, accept any password 98 | // In production, this would verify the password hash 99 | // For now, we'll accept any non-empty password if the user exists 100 | if password == "" { 101 | return nil, errors.New("password required") 102 | } 103 | 104 | // TODO: In production, verify password hash here 105 | // For development with DISABLE_AUTH, we just check if user exists 106 | 107 | return user, nil 108 | } 109 | 110 | // FindUserByScreenName finds a user by their screen name. 111 | // This is just an alias for the User method to satisfy the UserManager interface. 112 | func (u *SQLiteUserStore) FindUserByScreenName(ctx context.Context, screenName IdentScreenName) (*User, error) { 113 | return u.User(ctx, screenName) 114 | } 115 | -------------------------------------------------------------------------------- /server/toc/mock_dir_search_service_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package toc 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/mk6i/retro-aim-server/wire" 11 | mock "github.com/stretchr/testify/mock" 12 | ) 13 | 14 | // newMockDirSearchService creates a new instance of mockDirSearchService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 15 | // The first argument is typically a *testing.T value. 16 | func newMockDirSearchService(t interface { 17 | mock.TestingT 18 | Cleanup(func()) 19 | }) *mockDirSearchService { 20 | mock := &mockDirSearchService{} 21 | mock.Mock.Test(t) 22 | 23 | t.Cleanup(func() { mock.AssertExpectations(t) }) 24 | 25 | return mock 26 | } 27 | 28 | // mockDirSearchService is an autogenerated mock type for the DirSearchService type 29 | type mockDirSearchService struct { 30 | mock.Mock 31 | } 32 | 33 | type mockDirSearchService_Expecter struct { 34 | mock *mock.Mock 35 | } 36 | 37 | func (_m *mockDirSearchService) EXPECT() *mockDirSearchService_Expecter { 38 | return &mockDirSearchService_Expecter{mock: &_m.Mock} 39 | } 40 | 41 | // InfoQuery provides a mock function for the type mockDirSearchService 42 | func (_mock *mockDirSearchService) InfoQuery(context1 context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0F_0x02_InfoQuery) (wire.SNACMessage, error) { 43 | ret := _mock.Called(context1, inFrame, inBody) 44 | 45 | if len(ret) == 0 { 46 | panic("no return value specified for InfoQuery") 47 | } 48 | 49 | var r0 wire.SNACMessage 50 | var r1 error 51 | if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0F_0x02_InfoQuery) (wire.SNACMessage, error)); ok { 52 | return returnFunc(context1, inFrame, inBody) 53 | } 54 | if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0F_0x02_InfoQuery) wire.SNACMessage); ok { 55 | r0 = returnFunc(context1, inFrame, inBody) 56 | } else { 57 | r0 = ret.Get(0).(wire.SNACMessage) 58 | } 59 | if returnFunc, ok := ret.Get(1).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0F_0x02_InfoQuery) error); ok { 60 | r1 = returnFunc(context1, inFrame, inBody) 61 | } else { 62 | r1 = ret.Error(1) 63 | } 64 | return r0, r1 65 | } 66 | 67 | // mockDirSearchService_InfoQuery_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InfoQuery' 68 | type mockDirSearchService_InfoQuery_Call struct { 69 | *mock.Call 70 | } 71 | 72 | // InfoQuery is a helper method to define mock.On call 73 | // - context1 context.Context 74 | // - inFrame wire.SNACFrame 75 | // - inBody wire.SNAC_0x0F_0x02_InfoQuery 76 | func (_e *mockDirSearchService_Expecter) InfoQuery(context1 interface{}, inFrame interface{}, inBody interface{}) *mockDirSearchService_InfoQuery_Call { 77 | return &mockDirSearchService_InfoQuery_Call{Call: _e.mock.On("InfoQuery", context1, inFrame, inBody)} 78 | } 79 | 80 | func (_c *mockDirSearchService_InfoQuery_Call) Run(run func(context1 context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0F_0x02_InfoQuery)) *mockDirSearchService_InfoQuery_Call { 81 | _c.Call.Run(func(args mock.Arguments) { 82 | var arg0 context.Context 83 | if args[0] != nil { 84 | arg0 = args[0].(context.Context) 85 | } 86 | var arg1 wire.SNACFrame 87 | if args[1] != nil { 88 | arg1 = args[1].(wire.SNACFrame) 89 | } 90 | var arg2 wire.SNAC_0x0F_0x02_InfoQuery 91 | if args[2] != nil { 92 | arg2 = args[2].(wire.SNAC_0x0F_0x02_InfoQuery) 93 | } 94 | run( 95 | arg0, 96 | arg1, 97 | arg2, 98 | ) 99 | }) 100 | return _c 101 | } 102 | 103 | func (_c *mockDirSearchService_InfoQuery_Call) Return(sNACMessage wire.SNACMessage, err error) *mockDirSearchService_InfoQuery_Call { 104 | _c.Call.Return(sNACMessage, err) 105 | return _c 106 | } 107 | 108 | func (_c *mockDirSearchService_InfoQuery_Call) RunAndReturn(run func(context1 context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0F_0x02_InfoQuery) (wire.SNACMessage, error)) *mockDirSearchService_InfoQuery_Call { 109 | _c.Call.Return(run) 110 | return _c 111 | } 112 | -------------------------------------------------------------------------------- /docs/MACOS.md: -------------------------------------------------------------------------------- 1 | # Open OSCAR Server Quickstart for macOS (Intel and Apple Silicon) 2 | 3 | This guide explains how to download, configure and run Open OSCAR Server on macOS (Intel and Apple Silicon). 4 | 5 | 1. **Download Open OSCAR Server** 6 | 7 | Grab the latest macOS release from the [Releases page](https://github.com/mk6i/open-oscar-server/releases) for your 8 | platform (Intel or Apple Silicon). 9 | 10 | Because the Open OSCAR Server binary has not been blessed by Apple, browsers such as Chrome may think it's a 11 | "suspicious" file and block the download, in which case you need to explicitly opt in to downloading the untrusted 12 | file. 13 | 14 |
15 |
16 |
6 |
7 |