From 7007c2c787f259766867c1375d993eda2986f2e1 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Mon, 13 Jul 2026 02:34:06 -0400 Subject: [PATCH] add saget and saset to API (#2421) --- docs/API.md | 32 +++++++++ irc/api.go | 202 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+) diff --git a/docs/API.md b/docs/API.md index d8b88238..2a75ec46 100644 --- a/docs/API.md +++ b/docs/API.md @@ -136,6 +136,38 @@ The response is a JSON object with fields: * `success`: whether the password change succeeded * `errorCode`: string, optional, machine-readable description of the error. Possible values include: `ACCOUNT_DOES_NOT_EXIST`, `INVALID_PASSPHRASE`, `CREDENTIALS_EXTERNALLY_MANAGED`, `UNKNOWN_ERROR`. +`/v1/ns/saget` +-------------- + +This endpoint retrieves account settings associated with an existing NickServ account. The request is a JSON object with fields: + +* `accountName`: string, name of the account + +The response is a JSON object with fields: + +* `success`: boolean, whether the request succeeded +* `errorCode`: string, optional, machine-readable description of the error if `success` is false. Possible values: `INVALID_REQUEST`, `ACCOUNT_DOES_NOT_EXIST`, `ACCOUNT_UNVERIFIED`, `UNKNOWN_ERROR`. If `success` is false, the remaining fields are omitted. +* `error`: string, optional, human-readable description of the failure if `success` is false and `errorCode` is `UNKNOWN_ERROR` +* `alwaysOn`, string, whether the user is always-on (`default`, `off`, or `on`) +* `autoAway`, string, whether to automatically set an always-on user as away when no client sessions are connected (`default`, `off`, or `on`) +* `email`, string, email address associated with the account +* `replayJoins`, string, whether to replay events like JOIN as PRIVMSGs from HistServ to legacy clients (`commands-only` or `on`) + +`/v1/ns/saset` +-------------- + +This endpoint modifies account settings associated with an existing NickServ account. The request is a JSON object with fields: + +* `accountName`: string, name of the account +* `alwaysOn`, string, optional, whether the user is always-on (`default`, `off`, or `on`) +* `autoAway`, string, optional, whether to automatically set an always-on user as away when no client sessions are connected (`default`, `off`, or `on`) +* `email`, string, optional, email address associated with the account +* `replayJoins`, string, optional, whether to replay events like JOIN as PRIVMSGs from HistServ to legacy clients (`commands-only` or `on`) + +Omitted fields are not updated (i.e. PATCH semantics). + +The response format is the same as `/v1/ns/saget`. + `/v1/ns/saregister` ------------------- diff --git a/irc/api.go b/irc/api.go index 4fe3334d..2a6dc67c 100644 --- a/irc/api.go +++ b/irc/api.go @@ -40,6 +40,8 @@ func newAPIHandler(server *Server) http.Handler { api.mux.HandleFunc("POST /v1/ns/list", api.handleAccountList) api.mux.HandleFunc("POST /v1/ns/passwd", api.handleNsPasswd) api.mux.HandleFunc("POST /v1/ns/saregister", api.handleSaregister) + api.mux.HandleFunc("POST /v1/ns/saget", api.handleNsSaget) + api.mux.HandleFunc("POST /v1/ns/saset", api.handleNsSaset) return api } @@ -248,6 +250,206 @@ func (a *ergoAPI) handleNsPasswd(w http.ResponseWriter, r *http.Request) { a.writeJSONResponse(response, w, r) } +// convert to/from the "default"/"off"/"on" vocabulary used by the +// /v1/ns/saget and /v1/ns/saset API endpoints +func apiPersistentStatusToString(status PersistentStatus) string { + switch status { + case PersistentDisabled: + return "off" + case PersistentMandatory: + return "on" + default: + return "default" + } +} + +func apiPersistentStatusFromString(status string) (PersistentStatus, error) { + switch strings.ToLower(status) { + case "default": + return PersistentUnspecified, nil + case "off": + return PersistentDisabled, nil + case "on": + return PersistentMandatory, nil + default: + return PersistentUnspecified, errInvalidParams + } +} + +// convert to/from the "commands-only"/"on" vocabulary used by the +// /v1/ns/saget and /v1/ns/saset API endpoints +func apiReplayJoinsToString(status ReplayJoinsSetting) string { + switch status { + case ReplayJoinsAlways: + return "on" + default: + return "commands-only" + } +} + +func apiReplayJoinsFromString(status string) (ReplayJoinsSetting, error) { + switch strings.ToLower(status) { + case "commands-only": + return ReplayJoinsCommandsOnly, nil + case "on": + return ReplayJoinsAlways, nil + default: + return ReplayJoinsCommandsOnly, errInvalidParams + } +} + +type apiAccountSettingsRequest struct { + AccountName string `json:"accountName"` +} + +type apiAccountSettingsResponse struct { + apiGenericResponse + AlwaysOn string `json:"alwaysOn,omitempty"` + AutoAway string `json:"autoAway,omitempty"` + Email string `json:"email,omitempty"` + ReplayJoins string `json:"replayJoins,omitempty"` +} + +// accountSettingsErrorCode maps an error from loading or modifying account +// settings to the machine-readable errorCode conventions shared by +// /v1/ns/saget and /v1/ns/saset. +func accountSettingsErrorCode(err error) string { + switch err { + case errAccountDoesNotExist: + return "ACCOUNT_DOES_NOT_EXIST" + case errAccountUnverified: + return "ACCOUNT_UNVERIFIED" + default: + return "UNKNOWN_ERROR" + } +} + +func accountSettingsErrorResponse(err error) apiAccountSettingsResponse { + response := apiAccountSettingsResponse{ + apiGenericResponse: apiGenericResponse{Success: false, ErrorCode: accountSettingsErrorCode(err)}, + } + if response.ErrorCode == "UNKNOWN_ERROR" { + response.Error = err.Error() + } + return response +} + +// loadVerifiedAccount loads an account and checks that it exists and is +// verified, returning the appropriate sentinel error otherwise. +func (a *ergoAPI) loadVerifiedAccount(accountName string) (accountData ClientAccount, err error) { + accountData, err = a.server.accounts.LoadAccount(accountName) + if err == nil && !accountData.Verified { + err = errAccountUnverified + } + return +} + +func (a *ergoAPI) accountSettingsResponse(accountName string) apiAccountSettingsResponse { + accountData, err := a.loadVerifiedAccount(accountName) + if err != nil { + return accountSettingsErrorResponse(err) + } + + settings := accountData.Settings + return apiAccountSettingsResponse{ + apiGenericResponse: apiGenericResponse{Success: true}, + AlwaysOn: apiPersistentStatusToString(settings.AlwaysOn), + AutoAway: apiPersistentStatusToString(settings.AutoAway), + Email: settings.Email, + ReplayJoins: apiReplayJoinsToString(settings.ReplayJoins), + } +} + +func (a *ergoAPI) handleNsSaget(w http.ResponseWriter, r *http.Request) { + var request apiAccountSettingsRequest + if err := a.decodeJSONRequest(&request, w, r); err != nil { + return + } + + if request.AccountName == "" { + a.writeJSONResponse(apiAccountSettingsResponse{ + apiGenericResponse: apiGenericResponse{Success: false, ErrorCode: "INVALID_REQUEST"}, + }, w, r) + return + } + + a.writeJSONResponse(a.accountSettingsResponse(request.AccountName), w, r) +} + +type apiNsSasetRequest struct { + AccountName string `json:"accountName"` + AlwaysOn *string `json:"alwaysOn"` + AutoAway *string `json:"autoAway"` + Email *string `json:"email"` + ReplayJoins *string `json:"replayJoins"` +} + +func (a *ergoAPI) handleNsSaset(w http.ResponseWriter, r *http.Request) { + var request apiNsSasetRequest + if err := a.decodeJSONRequest(&request, w, r); err != nil { + return + } + + if request.AccountName == "" { + a.writeJSONResponse(apiAccountSettingsResponse{ + apiGenericResponse: apiGenericResponse{Success: false, ErrorCode: "INVALID_REQUEST"}, + }, w, r) + return + } + + var alwaysOn, autoAway PersistentStatus + var replayJoins ReplayJoinsSetting + var err error + if request.AlwaysOn != nil { + if alwaysOn, err = apiPersistentStatusFromString(*request.AlwaysOn); err != nil { + a.writeJSONResponse(apiAccountSettingsResponse{ + apiGenericResponse: apiGenericResponse{Success: false, ErrorCode: "INVALID_REQUEST"}, + }, w, r) + return + } + } + if request.AutoAway != nil { + if autoAway, err = apiPersistentStatusFromString(*request.AutoAway); err != nil { + a.writeJSONResponse(apiAccountSettingsResponse{ + apiGenericResponse: apiGenericResponse{Success: false, ErrorCode: "INVALID_REQUEST"}, + }, w, r) + return + } + } + if request.ReplayJoins != nil { + if replayJoins, err = apiReplayJoinsFromString(*request.ReplayJoins); err != nil { + a.writeJSONResponse(apiAccountSettingsResponse{ + apiGenericResponse: apiGenericResponse{Success: false, ErrorCode: "INVALID_REQUEST"}, + }, w, r) + return + } + } + + munger := func(in AccountSettings) (out AccountSettings, err error) { + out = in + if request.AlwaysOn != nil { + out.AlwaysOn = alwaysOn + } + if request.AutoAway != nil { + out.AutoAway = autoAway + } + if request.Email != nil { + out.Email = *request.Email + } + if request.ReplayJoins != nil { + out.ReplayJoins = replayJoins + } + return + } + + if _, err := a.server.accounts.ModifyAccountSettings(request.AccountName, munger); err != nil { + a.writeJSONResponse(accountSettingsErrorResponse(err), w, r) + return + } + + a.writeJSONResponse(a.accountSettingsResponse(request.AccountName), w, r) +} + type apiAccountDetailsResponse struct { apiGenericResponse AccountName string `json:"accountName,omitempty"`