mirror of
https://github.com/ergochat/ergo.git
synced 2026-03-13 10:48:05 +01:00
436 lines
12 KiB
Go
436 lines
12 KiB
Go
package irc
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"github.com/ergochat/ergo/irc/modes"
|
|
"github.com/ergochat/ergo/irc/sno"
|
|
"github.com/ergochat/ergo/irc/utils"
|
|
)
|
|
|
|
func newAPIHandler(server *Server) http.Handler {
|
|
api := &ergoAPI{
|
|
server: server,
|
|
mux: http.NewServeMux(),
|
|
}
|
|
|
|
// server-level functionality:
|
|
api.mux.HandleFunc("POST /v1/rehash", api.handleRehash)
|
|
api.mux.HandleFunc("POST /v1/status", api.handleStatus)
|
|
api.mux.HandleFunc("POST /v1/list", api.handleList)
|
|
api.mux.HandleFunc("POST /v1/defcon", api.handleDefcon)
|
|
|
|
// use Ergo as a source of truth for authentication in other services:
|
|
api.mux.HandleFunc("POST /v1/check_auth", api.handleCheckAuth)
|
|
|
|
// legacy names for /v1/ns endpoints:
|
|
api.mux.HandleFunc("POST /v1/saregister", api.handleSaregister)
|
|
api.mux.HandleFunc("POST /v1/account_details", api.handleAccountDetails)
|
|
api.mux.HandleFunc("POST /v1/account_list", api.handleAccountList)
|
|
|
|
// /v1/ns: nickserv functionality
|
|
api.mux.HandleFunc("POST /v1/ns/info", api.handleAccountDetails)
|
|
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)
|
|
|
|
return api
|
|
}
|
|
|
|
type ergoAPI struct {
|
|
server *Server
|
|
mux *http.ServeMux
|
|
}
|
|
|
|
func (a *ergoAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
defer a.server.HandlePanic(nil)
|
|
defer a.server.logger.Debug("api", r.URL.Path)
|
|
|
|
if a.checkBearerAuth(r.Header.Get("Authorization")) {
|
|
a.mux.ServeHTTP(w, r)
|
|
} else {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
}
|
|
}
|
|
|
|
func (a *ergoAPI) checkBearerAuth(authHeader string) (authorized bool) {
|
|
if authHeader == "" {
|
|
return false
|
|
}
|
|
c := a.server.Config()
|
|
if !c.API.Enabled {
|
|
return false
|
|
}
|
|
spaceIdx := strings.IndexByte(authHeader, ' ')
|
|
if spaceIdx < 0 {
|
|
return false
|
|
}
|
|
if !strings.EqualFold("Bearer", authHeader[:spaceIdx]) {
|
|
return false
|
|
}
|
|
providedTokenBytes := []byte(authHeader[spaceIdx+1:])
|
|
for _, tokenBytes := range c.API.bearerTokenBytes {
|
|
if subtle.ConstantTimeCompare(tokenBytes, providedTokenBytes) == 1 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (a *ergoAPI) decodeJSONRequest(request any, w http.ResponseWriter, r *http.Request) (err error) {
|
|
err = json.NewDecoder(r.Body).Decode(request)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to deserialize json request: %v", err), http.StatusBadRequest)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (a *ergoAPI) writeJSONResponse(response any, w http.ResponseWriter, r *http.Request) {
|
|
j, err := json.Marshal(response)
|
|
if err == nil {
|
|
j = append(j, '\n') // less annoying in curl output
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write(j)
|
|
} else {
|
|
a.server.logger.Error("internal", "failed to serialize API response", r.URL.Path, err.Error())
|
|
http.Error(w, fmt.Sprintf("failed to serialize json response: %v", err), http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
type apiGenericResponse struct {
|
|
Success bool `json:"success"`
|
|
Error string `json:"error,omitempty"`
|
|
ErrorCode string `json:"errorCode,omitempty"`
|
|
}
|
|
|
|
func (a *ergoAPI) handleRehash(w http.ResponseWriter, r *http.Request) {
|
|
var response apiGenericResponse
|
|
err := a.server.rehash()
|
|
if err == nil {
|
|
response.Success = true
|
|
} else {
|
|
response.Success = false
|
|
response.Error = err.Error()
|
|
}
|
|
a.writeJSONResponse(response, w, r)
|
|
}
|
|
|
|
type defconRequestResponse struct {
|
|
apiGenericResponse
|
|
Defcon int `json:"defcon"`
|
|
}
|
|
|
|
func (a *ergoAPI) handleDefcon(w http.ResponseWriter, r *http.Request) {
|
|
var changeRequested uint32
|
|
var request defconRequestResponse
|
|
// ignore errors or invalid values
|
|
if err := json.NewDecoder(r.Body).Decode(&request); err == nil {
|
|
if 1 <= request.Defcon && request.Defcon <= 5 {
|
|
changeRequested = uint32(request.Defcon)
|
|
}
|
|
}
|
|
if changeRequested != 0 {
|
|
a.server.SetDefcon(changeRequested)
|
|
message := fmt.Sprintf("API set DEFCON level to %d", changeRequested)
|
|
a.server.logger.Info("server", message)
|
|
a.server.snomasks.Send(sno.LocalAnnouncements, message)
|
|
}
|
|
a.writeJSONResponse(
|
|
defconRequestResponse{
|
|
apiGenericResponse: apiGenericResponse{Success: true},
|
|
Defcon: int(a.server.Defcon()),
|
|
}, w, r,
|
|
)
|
|
}
|
|
|
|
type apiCheckAuthResponse struct {
|
|
apiGenericResponse
|
|
AccountName string `json:"accountName,omitempty"`
|
|
}
|
|
|
|
func (a *ergoAPI) handleCheckAuth(w http.ResponseWriter, r *http.Request) {
|
|
var request AuthScriptInput
|
|
if err := a.decodeJSONRequest(&request, w, r); err != nil {
|
|
return
|
|
}
|
|
|
|
var response apiCheckAuthResponse
|
|
|
|
var account ClientAccount
|
|
var err error
|
|
|
|
// try whatever credentials are present
|
|
if request.AccountName != "" && request.Passphrase != "" {
|
|
account, err = a.server.accounts.checkPassphrase(request.AccountName, request.Passphrase)
|
|
} else if request.Certfp != "" {
|
|
account, err = a.server.accounts.checkCertAuth(nil, request.Certfp, nil, "")
|
|
} else {
|
|
err = errAccountInvalidCredentials
|
|
}
|
|
|
|
switch err {
|
|
case nil:
|
|
// success, no error
|
|
response.Success = true
|
|
response.AccountName = account.Name
|
|
case errAccountDoesNotExist, errAccountInvalidCredentials, errAccountUnverified, errAccountSuspended:
|
|
// fail, no error
|
|
response.Success = false
|
|
default:
|
|
response.Success = false
|
|
response.Error = err.Error()
|
|
}
|
|
|
|
a.writeJSONResponse(response, w, r)
|
|
}
|
|
|
|
type apiSaregisterRequest struct {
|
|
AccountName string `json:"accountName"`
|
|
Passphrase string `json:"passphrase"`
|
|
}
|
|
|
|
func (a *ergoAPI) handleSaregister(w http.ResponseWriter, r *http.Request) {
|
|
var request apiSaregisterRequest
|
|
if err := a.decodeJSONRequest(&request, w, r); err != nil {
|
|
return
|
|
}
|
|
|
|
var response apiGenericResponse
|
|
err := a.server.accounts.SARegister(request.AccountName, request.Passphrase)
|
|
if err == nil {
|
|
response.Success = true
|
|
} else {
|
|
response.Success = false
|
|
response.Error = err.Error()
|
|
switch err {
|
|
case errAccountAlreadyRegistered, errAccountAlreadyVerified, errNameReserved:
|
|
response.ErrorCode = "ACCOUNT_EXISTS"
|
|
case errAccountBadPassphrase:
|
|
response.ErrorCode = "INVALID_PASSPHRASE"
|
|
default:
|
|
response.ErrorCode = "UNKNOWN_ERROR"
|
|
}
|
|
}
|
|
|
|
a.writeJSONResponse(response, w, r)
|
|
}
|
|
|
|
func (a *ergoAPI) handleNsPasswd(w http.ResponseWriter, r *http.Request) {
|
|
var request apiSaregisterRequest
|
|
if err := a.decodeJSONRequest(&request, w, r); err != nil {
|
|
return
|
|
}
|
|
|
|
var response apiGenericResponse
|
|
err := a.server.accounts.setPassword(request.AccountName, request.Passphrase, true)
|
|
switch err {
|
|
case nil:
|
|
response.Success = true
|
|
case errAccountDoesNotExist:
|
|
response.ErrorCode = "ACCOUNT_DOES_NOT_EXIST"
|
|
case errAccountBadPassphrase, errEmptyCredentials:
|
|
response.ErrorCode = "INVALID_PASSPHRASE"
|
|
case errCredsExternallyManaged:
|
|
response.ErrorCode = "CREDENTIALS_EXTERNALLY_MANAGED"
|
|
default:
|
|
a.server.logger.Error("api", "could not change user password:", err.Error())
|
|
response.ErrorCode = "UNKNOWN_ERROR"
|
|
}
|
|
|
|
a.writeJSONResponse(response, w, r)
|
|
}
|
|
|
|
type apiAccountDetailsResponse struct {
|
|
apiGenericResponse
|
|
AccountName string `json:"accountName,omitempty"`
|
|
Email string `json:"email,omitempty"`
|
|
RegisteredAt string `json:"registeredAt,omitempty"`
|
|
Channels []string `json:"channels,omitempty"`
|
|
}
|
|
|
|
type apiAccountDetailsRequest struct {
|
|
AccountName string `json:"accountName"`
|
|
}
|
|
|
|
func (a *ergoAPI) handleAccountDetails(w http.ResponseWriter, r *http.Request) {
|
|
var request apiAccountDetailsRequest
|
|
if err := a.decodeJSONRequest(&request, w, r); err != nil {
|
|
return
|
|
}
|
|
|
|
var response apiAccountDetailsResponse
|
|
|
|
if request.AccountName != "" {
|
|
accountData, err := a.server.accounts.LoadAccount(request.AccountName)
|
|
if err == nil {
|
|
if !accountData.Verified {
|
|
err = errAccountUnverified
|
|
} else if accountData.Suspended != nil {
|
|
err = errAccountSuspended
|
|
}
|
|
}
|
|
|
|
switch err {
|
|
case nil:
|
|
response.AccountName = accountData.Name
|
|
response.Email = accountData.Settings.Email
|
|
if !accountData.RegisteredAt.IsZero() {
|
|
response.RegisteredAt = accountData.RegisteredAt.Format(utils.IRCv3TimestampFormat)
|
|
}
|
|
|
|
// Get channels the account is in
|
|
response.Channels = a.server.channels.ChannelsForAccount(accountData.NameCasefolded)
|
|
response.Success = true
|
|
case errAccountDoesNotExist, errAccountUnverified, errAccountSuspended:
|
|
response.Success = false
|
|
default:
|
|
response.Success = false
|
|
response.ErrorCode = "UNKNOWN_ERROR"
|
|
response.Error = err.Error()
|
|
}
|
|
} else {
|
|
response.Success = false
|
|
response.ErrorCode = "INVALID_REQUEST"
|
|
}
|
|
|
|
a.writeJSONResponse(response, w, r)
|
|
}
|
|
|
|
type apiAccountListResponse struct {
|
|
apiGenericResponse
|
|
Accounts []apiAccountDetailsResponse `json:"accounts"`
|
|
TotalCount int `json:"totalCount"`
|
|
}
|
|
|
|
func (a *ergoAPI) handleAccountList(w http.ResponseWriter, r *http.Request) {
|
|
var response apiAccountListResponse
|
|
|
|
// Get all account names
|
|
accounts := a.server.accounts.AllNicks()
|
|
response.TotalCount = len(accounts)
|
|
|
|
// Load account details
|
|
response.Accounts = make([]apiAccountDetailsResponse, 0, len(accounts))
|
|
for _, account := range accounts {
|
|
accountData, err := a.server.accounts.LoadAccount(account)
|
|
if err != nil {
|
|
// shouldn't happen
|
|
continue
|
|
}
|
|
|
|
response.Accounts = append(
|
|
response.Accounts,
|
|
apiAccountDetailsResponse{
|
|
apiGenericResponse: apiGenericResponse{
|
|
Success: true,
|
|
},
|
|
AccountName: accountData.Name,
|
|
Email: accountData.Settings.Email,
|
|
},
|
|
)
|
|
}
|
|
|
|
response.Success = true
|
|
a.writeJSONResponse(response, w, r)
|
|
}
|
|
|
|
type apiStatusResponse struct {
|
|
apiGenericResponse
|
|
Version string `json:"version"`
|
|
GoVersion string `json:"go_version"`
|
|
Commit string `json:"commit,omitempty"`
|
|
StartTime string `json:"start_time"`
|
|
Users struct {
|
|
Total int `json:"total"`
|
|
Invisible int `json:"invisible"`
|
|
Operators int `json:"operators"`
|
|
Unknown int `json:"unknown"`
|
|
Max int `json:"max"`
|
|
} `json:"users"`
|
|
Channels int `json:"channels"`
|
|
Servers int `json:"servers"`
|
|
}
|
|
|
|
func (a *ergoAPI) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|
server := a.server
|
|
stats := server.stats.GetValues()
|
|
|
|
response := apiStatusResponse{
|
|
apiGenericResponse: apiGenericResponse{Success: true},
|
|
Version: SemVer,
|
|
GoVersion: runtime.Version(),
|
|
Commit: Commit,
|
|
StartTime: server.ctime.Format(utils.IRCv3TimestampFormat),
|
|
}
|
|
|
|
response.Users.Total = stats.Total
|
|
response.Users.Invisible = stats.Invisible
|
|
response.Users.Operators = stats.Operators
|
|
response.Users.Unknown = stats.Unknown
|
|
response.Users.Max = stats.Max
|
|
response.Channels = server.channels.Len()
|
|
response.Servers = 1
|
|
|
|
a.writeJSONResponse(response, w, r)
|
|
}
|
|
|
|
type apiChannelData struct {
|
|
Name string `json:"name"`
|
|
HasKey bool `json:"hasKey"`
|
|
InviteOnly bool `json:"inviteOnly"`
|
|
Secret bool `json:"secret"`
|
|
UserCount int `json:"userCount"`
|
|
Topic string `json:"topic"`
|
|
TopicSetAt string `json:"topicSetAt,omitempty"`
|
|
CreatedAt string `json:"createdAt"`
|
|
Registered bool `json:"registered"`
|
|
Owner string `json:"owner,omitempty"`
|
|
RegisteredAt string `json:"registeredAt,omitempty"`
|
|
}
|
|
|
|
func (channel *Channel) apiData() (result apiChannelData) {
|
|
channel.stateMutex.RLock()
|
|
defer channel.stateMutex.RUnlock()
|
|
result.Name = channel.name
|
|
result.HasKey = channel.key != ""
|
|
result.InviteOnly = channel.flags.HasMode(modes.InviteOnly)
|
|
result.Secret = channel.flags.HasMode(modes.Secret)
|
|
result.UserCount = len(channel.members)
|
|
result.Topic = channel.topic
|
|
if !channel.topicSetTime.IsZero() {
|
|
result.TopicSetAt = channel.topicSetTime.UTC().Format(utils.IRCv3TimestampFormat)
|
|
}
|
|
result.CreatedAt = channel.createdTime.UTC().Format(utils.IRCv3TimestampFormat)
|
|
result.Registered = channel.registeredFounder != ""
|
|
if result.Registered {
|
|
result.Owner = channel.registeredFounder
|
|
if !channel.registeredTime.IsZero() {
|
|
result.RegisteredAt = channel.registeredTime.UTC().Format(utils.IRCv3TimestampFormat)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
type apiListResponse struct {
|
|
apiGenericResponse
|
|
Channels []apiChannelData `json:"channels"`
|
|
}
|
|
|
|
func (a *ergoAPI) handleList(w http.ResponseWriter, r *http.Request) {
|
|
channels := a.server.channels.ListableChannels()
|
|
response := apiListResponse{
|
|
apiGenericResponse: apiGenericResponse{Success: true},
|
|
Channels: make([]apiChannelData, 0, len(channels)),
|
|
}
|
|
for _, channel := range channels {
|
|
response.Channels = append(response.Channels, channel.apiData())
|
|
}
|
|
a.writeJSONResponse(response, w, r)
|
|
}
|