3
0
mirror of https://github.com/ergochat/ergo.git synced 2025-04-03 22:38:16 +02:00
ergo/irc/api.go
Shivaram Lingamneni ea81ec86e1
MVP for HTTP API (#2231)
Co-authored-by: Klaas Tammling <klaas@tammling.hamburg>
2025-03-18 23:13:03 -04:00

225 lines
5.8 KiB
Go

package irc
import (
"crypto/subtle"
"encoding/json"
"fmt"
"net/http"
"strings"
)
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)
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 != "" {
// TODO this only checks the internal database, not auth-script;
// it's a little weird to use both auth-script and the API but we should probably handle it
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"`
}
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
// TODO could probably use better error handling and more details
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
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)
}