From 5a62ffeb3efca3ee975ab0d49dffe732417e5412 Mon Sep 17 00:00:00 2001 From: 86Yin <243050688+86Yin@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:39:15 +0800 Subject: [PATCH 1/4] Add clan tag support to accounts --- db/atlasdb/001_init_db.go | 3 +- db/atlasdb/db.go | 7 +- pkg/api/api0/accounts.go | 111 ++++++++++++++++++++++++ pkg/api/api0/api.go | 4 + pkg/api/api0/api0gameserver/nsserver.go | 5 +- pkg/api/api0/client.go | 3 +- pkg/api/api0/storage.go | 3 + 7 files changed, 130 insertions(+), 6 deletions(-) diff --git a/db/atlasdb/001_init_db.go b/db/atlasdb/001_init_db.go index 8550fc0..3993c77 100644 --- a/db/atlasdb/001_init_db.go +++ b/db/atlasdb/001_init_db.go @@ -17,13 +17,14 @@ func up001(ctx context.Context, tx *sqlx.Tx) error { CREATE TABLE accounts ( uid TEXT PRIMARY KEY NOT NULL, username TEXT NOT NULL DEFAULT '' COLLATE NOCASE, + clan_tag TEXT NOT NULL DEFAULT '' COLLATE NOCASE, auth_ip TEXT, auth_token TEXT, auth_expiry INTEGER, last_server TEXT ) STRICT; `, ` - `, "\n")); err != nil { + `, "\n")); err != nil { return fmt.Errorf("create accounts table: %w", err) } if _, err := tx.ExecContext(ctx, `CREATE INDEX accounts_username_idx ON accounts(username, uid)`); err != nil { diff --git a/db/atlasdb/db.go b/db/atlasdb/db.go index 79eb3c0..c694a7d 100644 --- a/db/atlasdb/db.go +++ b/db/atlasdb/db.go @@ -53,6 +53,7 @@ func (db *DB) GetAccount(uid uint64) (*api0.Account, error) { var obj struct { UID uint64 `db:"uid"` Username string `db:"username"` + ClanTag string `db:"clan_tag"` AuthIP string `db:"auth_ip"` AuthToken string `db:"auth_token"` AuthExpiry int64 `db:"auth_expiry"` @@ -82,6 +83,7 @@ func (db *DB) GetAccount(uid uint64) (*api0.Account, error) { return &api0.Account{ UID: obj.UID, Username: obj.Username, + ClanTag: obj.ClanTag, AuthIP: authIP, AuthToken: obj.AuthToken, AuthTokenExpiry: authExpiry, @@ -102,11 +104,12 @@ func (db *DB) SaveAccount(a *api0.Account) error { if _, err := db.x.NamedExec(` INSERT OR REPLACE INTO - accounts ( uid, username, auth_ip, auth_token, auth_expiry, last_server) - VALUES (:uid, :username, :auth_ip, :auth_token, :auth_expiry, :last_server) + accounts ( uid, username, clan_tag, auth_ip, auth_token, auth_expiry, last_server) + VALUES (:uid, :username, :clan_tag, :auth_ip, :auth_token, :auth_expiry, :last_server) `, map[string]any{ "uid": a.UID, "username": a.Username, + "clan_tag": a.ClanTag, "auth_ip": authIP, "auth_token": a.AuthToken, "auth_expiry": authExpiry, diff --git a/pkg/api/api0/accounts.go b/pkg/api/api0/accounts.go index 215d0a3..f8e49bf 100644 --- a/pkg/api/api0/accounts.go +++ b/pkg/api/api0/accounts.go @@ -5,6 +5,10 @@ import ( "net/http" "net/netip" "strconv" + "strings" + "time" + "unicode/utf8" + "regexp" "github.com/r2northstar/atlas/pkg/pdata" "github.com/rs/zerolog/hlog" @@ -301,3 +305,110 @@ func (h *Handler) handleAccountsGetUsername(w http.ResponseWriter, r *http.Reque "matches": []string{username}, // yes, this may be an empty string if we don't know what it is }) } + +func (h *Handler) handleAccountsSetClanTag(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodOptions && r.Method != http.MethodPost { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return + } + + if r.Method == http.MethodOptions { + w.Header().Set("Allow", "OPTIONS, POST") + w.WriteHeader(http.StatusNoContent) + return + } + + uidQ := r.URL.Query().Get("id") + if uidQ == "" { + respFail(w, r, http.StatusBadRequest, ErrorCode_BAD_REQUEST.MessageObjf("id param is required")) + return + } + uid, err := strconv.ParseUint(uidQ, 10, 64) + if err != nil { + respFail(w, r, http.StatusNotFound, ErrorCode_PLAYER_NOT_FOUND.MessageObj()) + return + } + + playerToken := r.URL.Query().Get("playerToken") + + acct, err := h.AccountStorage.GetAccount(uid) + if err != nil { + respFail(w, r, http.StatusInternalServerError, ErrorCode_INTERNAL_SERVER_ERROR.MessageObj()) + return + } + if acct == nil { + respFail(w, r, http.StatusNotFound, ErrorCode_PLAYER_NOT_FOUND.MessageObj()) + return + } + + if !h.InsecureDevNoCheckPlayerAuth { + if playerToken != acct.AuthToken || !time.Now().Before(acct.AuthTokenExpiry) { + respFail(w, r, http.StatusUnauthorized, ErrorCode_INVALID_MASTERSERVER_TOKEN.MessageObj()) + return + } + } + + tag := r.URL.Query().Get("tag") + if tag == "" && r.Header.Get("Content-Type") == "application/x-www-form-urlencoded" { + if err := r.ParseForm(); err == nil { + tag = r.Form.Get("tag") + } + } + tag = strings.TrimSpace(tag) + if h.CleanBadWords != nil { + tag = h.CleanBadWords(tag) + } + + if tag != "" { + if utf8.RuneCountInString(tag) > 4 { + respFail(w, r, http.StatusBadRequest, ErrorCode_BAD_REQUEST.MessageObjf("tag must be at most 4 characters")) + return + } + // allow ASCII letters, digits and CJK (Han) characters only + ok, _ := regexp.MatchString(`^[A-Za-z0-9\p{Han}]+$`, tag) + if !ok { + respFail(w, r, http.StatusBadRequest, ErrorCode_BAD_REQUEST.MessageObjf("tag may only contain letters, numbers and Chinese characters")) + return + } + } + + acct.ClanTag = tag + if err := h.AccountStorage.SaveAccount(acct); err != nil { + respFail(w, r, http.StatusInternalServerError, ErrorCode_INTERNAL_SERVER_ERROR.MessageObj()) + return + } + respJSON(w, r, http.StatusOK, map[string]any{"success": true}) +} + +func (h *Handler) handleAccountsGetClanTag(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodOptions && r.Method != http.MethodHead && r.Method != http.MethodGet { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return + } + if r.Method == http.MethodOptions { + w.Header().Set("Allow", "OPTIONS, HEAD, GET") + w.WriteHeader(http.StatusNoContent) + return + } + + uidQ := r.URL.Query().Get("uid") + if uidQ == "" { + respFail(w, r, http.StatusBadRequest, ErrorCode_BAD_REQUEST.MessageObjf("uid param is required")) + return + } + uid, err := strconv.ParseUint(uidQ, 10, 64) + if err != nil { + respFail(w, r, http.StatusNotFound, ErrorCode_PLAYER_NOT_FOUND.MessageObj()) + return + } + acct, err := h.AccountStorage.GetAccount(uid) + if err != nil { + respFail(w, r, http.StatusInternalServerError, ErrorCode_INTERNAL_SERVER_ERROR.MessageObj()) + return + } + var tag string + if acct != nil { + tag = acct.ClanTag + } + respJSON(w, r, http.StatusOK, map[string]any{"success": true, "uid": strconv.FormatUint(uid, 10), "clanTag": tag}) +} diff --git a/pkg/api/api0/api.go b/pkg/api/api0/api.go index 642ed06..bfa12e8 100644 --- a/pkg/api/api0/api.go +++ b/pkg/api/api0/api.go @@ -152,6 +152,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.handleAccountsWritePersistence(w, r) case "/accounts/get_username": h.handleAccountsGetUsername(w, r) + case "/accounts/set_clan_tag": + h.handleAccountsSetClanTag(w, r) + case "/accounts/get_clan_tag": + h.handleAccountsGetClanTag(w, r) case "/accounts/lookup_uid": h.handleAccountsLookupUID(w, r) case "/player/pdata", "/player/info", "/player/stats", "/player/loadout": diff --git a/pkg/api/api0/api0gameserver/nsserver.go b/pkg/api/api0/api0gameserver/nsserver.go index 38fa0ba..3cf98f1 100644 --- a/pkg/api/api0/api0gameserver/nsserver.go +++ b/pkg/api/api0/api0gameserver/nsserver.go @@ -68,12 +68,13 @@ func Verify(ctx context.Context, auth netip.AddrPort) error { // registers a one-time connection token, and sends the player's pdata. If the // authentication request returns invalid JSON, err is ErrInvalidResponse. If // the authentication response .success is false, err is ErrAuthFailed. -func AuthenticateIncomingPlayer(ctx context.Context, auth netip.AddrPort, uid uint64, username, connToken, serverToken string, pdata []byte) error { +func AuthenticateIncomingPlayer(ctx context.Context, auth netip.AddrPort, uid uint64, username, connToken, serverToken, clanTag string, pdata []byte) error { u := "http://" + auth.String() + "/authenticate_incoming_player" + "?id=" + strconv.FormatUint(uid, 10) + "&authToken=" + url.QueryEscape(connToken) + "&serverAuthToken=" + url.QueryEscape(serverToken) + - "&username=" + url.QueryEscape(username) + "&username=" + url.QueryEscape(username) + + "&clanTag=" + url.QueryEscape(clanTag) req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(pdata)) if err != nil { diff --git a/pkg/api/api0/client.go b/pkg/api/api0/client.go index c2f815e..5559027 100644 --- a/pkg/api/api0/client.go +++ b/pkg/api/api0/client.go @@ -445,7 +445,7 @@ func (h *Handler) handleClientAuthWithServer(w http.ResponseWriter, r *http.Requ defer cancel() if srv.AuthPort != 0 { - if err := api0gameserver.AuthenticateIncomingPlayer(ctx, srv.AuthAddr(), acct.UID, acct.Username, authToken, srv.ServerAuthToken, pbuf); err != nil { + if err := api0gameserver.AuthenticateIncomingPlayer(ctx, srv.AuthAddr(), acct.UID, acct.Username, authToken, srv.ServerAuthToken, acct.ClanTag, pbuf); err != nil { h.m().client_authwithserver_gameserverauth_duration_seconds.UpdateDuration(authStart) if errors.Is(err, context.DeadlineExceeded) { err = fmt.Errorf("request timed out") @@ -485,6 +485,7 @@ func (h *Handler) handleClientAuthWithServer(w http.ResponseWriter, r *http.Requ "token": authToken, "uid": acct.UID, "username": acct.Username, + "clanTag": acct.ClanTag, "ip": raddr.Addr().String(), "time": time.Now().Unix(), } diff --git a/pkg/api/api0/storage.go b/pkg/api/api0/storage.go index a6de4cd..73a5808 100644 --- a/pkg/api/api0/storage.go +++ b/pkg/api/api0/storage.go @@ -15,6 +15,9 @@ type Account struct { // optional and case insensitive. Username string + // ClanTag is the player's chosen clan tag. + ClanTag string + // AuthIP is the IP used for the current auth session. AuthIP netip.Addr From d4c6936d380ae369e1d356f32f1ff36644c19517 Mon Sep 17 00:00:00 2001 From: 86Yin <243050688+86Yin@users.noreply.github.com> Date: Sat, 7 Mar 2026 14:05:33 +0800 Subject: [PATCH 2/4] source clan tag from URL only; add DB migration for clan_tag --- db/atlasdb/001_init_db.go | 1 - db/atlasdb/002_add_clan_tag.go | 48 ++++++++++++++++++++++++++++++++++ pkg/api/api0/accounts.go | 5 ---- 3 files changed, 48 insertions(+), 6 deletions(-) create mode 100644 db/atlasdb/002_add_clan_tag.go diff --git a/db/atlasdb/001_init_db.go b/db/atlasdb/001_init_db.go index 3993c77..e4c4744 100644 --- a/db/atlasdb/001_init_db.go +++ b/db/atlasdb/001_init_db.go @@ -17,7 +17,6 @@ func up001(ctx context.Context, tx *sqlx.Tx) error { CREATE TABLE accounts ( uid TEXT PRIMARY KEY NOT NULL, username TEXT NOT NULL DEFAULT '' COLLATE NOCASE, - clan_tag TEXT NOT NULL DEFAULT '' COLLATE NOCASE, auth_ip TEXT, auth_token TEXT, auth_expiry INTEGER, diff --git a/db/atlasdb/002_add_clan_tag.go b/db/atlasdb/002_add_clan_tag.go new file mode 100644 index 0000000..0d790ae --- /dev/null +++ b/db/atlasdb/002_add_clan_tag.go @@ -0,0 +1,48 @@ +package atlasdb + +import ( + "context" + "fmt" + "strings" + + "github.com/jmoiron/sqlx" +) + +func init() { + migrate(up002, down002) +} + +func up002(ctx context.Context, tx *sqlx.Tx) error { + if _, err := tx.ExecContext(ctx, `ALTER TABLE accounts ADD COLUMN clan_tag TEXT NOT NULL DEFAULT '' COLLATE NOCASE`); err != nil { + return fmt.Errorf("add clan_tag column: %w", err) + } + return nil +} + +func down002(ctx context.Context, tx *sqlx.Tx) error { + if _, err := tx.ExecContext(ctx, strings.ReplaceAll(` + CREATE TABLE accounts_new ( + uid TEXT PRIMARY KEY NOT NULL, + username TEXT NOT NULL DEFAULT '' COLLATE NOCASE, + auth_ip TEXT, + auth_token TEXT, + auth_expiry INTEGER, + last_server TEXT + ) STRICT; + `, "\n", "\n")); err != nil { + return fmt.Errorf("create accounts_new table: %w", err) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO accounts_new(uid, username, auth_ip, auth_token, auth_expiry, last_server) SELECT uid, username, auth_ip, auth_token, auth_expiry, last_server FROM accounts`); err != nil { + return fmt.Errorf("copy accounts data: %w", err) + } + if _, err := tx.ExecContext(ctx, `DROP TABLE accounts`); err != nil { + return fmt.Errorf("drop accounts: %w", err) + } + if _, err := tx.ExecContext(ctx, `ALTER TABLE accounts_new RENAME TO accounts`); err != nil { + return fmt.Errorf("rename accounts_new: %w", err) + } + if _, err := tx.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS accounts_username_idx ON accounts(username, uid)`); err != nil { + return fmt.Errorf("create accounts index: %w", err) + } + return nil +} diff --git a/pkg/api/api0/accounts.go b/pkg/api/api0/accounts.go index f8e49bf..2af0479 100644 --- a/pkg/api/api0/accounts.go +++ b/pkg/api/api0/accounts.go @@ -349,11 +349,6 @@ func (h *Handler) handleAccountsSetClanTag(w http.ResponseWriter, r *http.Reques } tag := r.URL.Query().Get("tag") - if tag == "" && r.Header.Get("Content-Type") == "application/x-www-form-urlencoded" { - if err := r.ParseForm(); err == nil { - tag = r.Form.Get("tag") - } - } tag = strings.TrimSpace(tag) if h.CleanBadWords != nil { tag = h.CleanBadWords(tag) From ac754be0ba6c420412e9e19c942cde07f497fcf6 Mon Sep 17 00:00:00 2001 From: 86Yin <243050688+86Yin@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:45:42 +0800 Subject: [PATCH 3/4] Update client.go --- pkg/api/api0/client.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/api/api0/client.go b/pkg/api/api0/client.go index 5559027..add22a0 100644 --- a/pkg/api/api0/client.go +++ b/pkg/api/api0/client.go @@ -670,6 +670,7 @@ func (h *Handler) handleClientAuthWithSelf(w http.ResponseWriter, r *http.Reques obj := map[string]any{ "success": true, "id": strconv.FormatUint(acct.UID, 10), + "clanTag": acct.ClanTag, } // the way we encode this is utterly absurd and inefficient, but we need to do it for backwards compatibility From 2c8f94eda6221598459b9361c2e7f98d954de38c Mon Sep 17 00:00:00 2001 From: 86yin <243050688+86Yin@users.noreply.github.com> Date: Sun, 26 Apr 2026 16:12:46 +0800 Subject: [PATCH 4/4] Update 002_add_clan_tag.go --- db/atlasdb/002_add_clan_tag.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/atlasdb/002_add_clan_tag.go b/db/atlasdb/002_add_clan_tag.go index 0d790ae..7ce95c1 100644 --- a/db/atlasdb/002_add_clan_tag.go +++ b/db/atlasdb/002_add_clan_tag.go @@ -13,7 +13,7 @@ func init() { } func up002(ctx context.Context, tx *sqlx.Tx) error { - if _, err := tx.ExecContext(ctx, `ALTER TABLE accounts ADD COLUMN clan_tag TEXT NOT NULL DEFAULT '' COLLATE NOCASE`); err != nil { + if _, err := tx.ExecContext(ctx, `ALTER TABLE accounts ADD COLUMN clan_tag TEXT NOT NULL DEFAULT ''`); err != nil { return fmt.Errorf("add clan_tag column: %w", err) } return nil