mirror of
https://github.com/ergochat/ergo.git
synced 2025-06-03 05:17:49 +02:00

Fixes #2257 and #2260 * add `/v1/status` endpoint * add `/v1/account_list` endpoint * add fields to `/v1/account_details` response
312 lines
8.3 KiB
Go
312 lines
8.3 KiB
Go
package irc
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"github.com/ergochat/ergo/irc/utils"
|
|
)
|
|
|
|
func newAPIHandler(server *Server) http.Handler {
|
|
api := &ergoAPI{
|
|
server: server,
|
|
mux: http.NewServeMux(),
|
|
}
|
|
|
|
api.mux.HandleFunc("POST /v1/rehash", api.handleRehash)
|
|
api.mux.HandleFunc("POST /v1/check_auth", api.handleCheckAuth)
|
|
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)
|
|
api.mux.HandleFunc("POST /v1/status", api.handleStatus)
|
|
|
|
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 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
|
|
|
|
// try passphrase if present
|
|
if request.AccountName != "" && request.Passphrase != "" {
|
|
account, err := a.server.accounts.checkPassphrase(request.AccountName, request.Passphrase)
|
|
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()
|
|
}
|
|
}
|
|
// try certfp if present
|
|
if !response.Success && request.Certfp != "" {
|
|
// TODO support cerftp
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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, len(accounts))
|
|
for i, account := range accounts {
|
|
accountData, err := a.server.accounts.LoadAccount(account)
|
|
if err != nil {
|
|
response.Accounts[i] = apiAccountDetailsResponse{
|
|
apiGenericResponse: apiGenericResponse{
|
|
Success: false,
|
|
Error: err.Error(),
|
|
},
|
|
}
|
|
continue
|
|
}
|
|
|
|
response.Accounts[i] = 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)
|
|
}
|