package toc
import (
"bytes"
"context"
"encoding/hex"
"errors"
"fmt"
"html/template"
"io"
"net"
"net/http"
"golang.org/x/net/html"
"github.com/mk6i/open-oscar-server/state"
"github.com/mk6i/open-oscar-server/wire"
)
// profileTpl is the profile lookup response go template.
const profileTpl = `
Profile Lookup
Username : {{- .ScreenName -}}
{{ .Profile }}
`
// directoryTpl is the directory search response go template.
const directoryTpl = `
Open OSCAR ServerDir Results
{{- if .Results -}}
{{- range .Results -}}
Screen Name: {{.ScreenName}}
{{- if .FirstName}}First Name: {{.FirstName}} {{- end -}}
{{- if .MiddleName}}Middle Name: {{.MiddleName}} {{- end -}}
{{- if .LastName}}Last Name: {{.LastName}} {{- end -}}
{{- if .MaidenName}}Maiden Name: {{.MaidenName}} {{- end -}}
{{- if .Country}}Country: {{.Country}} {{- end -}}
{{- if .State}}State: {{.State}} {{- end -}}
{{- if .City}}City: {{.City}} {{- end -}}
{{- if .NickName}}Nick Name: {{.NickName}} {{- end -}}
{{- if .ZIP}}ZIP Code: {{.ZIP}} {{- end -}}
{{- if .Address}}Address : {{.Address}} {{- end -}}
|
{{- end -}}
{{- else -}}
No results found.
{{- end -}}
`
var (
profileTemplate *template.Template
directoryTemplate *template.Template
)
func init() {
var err error
profileTemplate, err = template.New("profile").Parse(profileTpl)
if err != nil {
panic(fmt.Errorf("failed to compile profile template: %w", err))
}
directoryTemplate, err = template.New("directory").Parse(directoryTpl)
if err != nil {
panic(fmt.Errorf("failed to compile directory template: %w", err))
}
}
// NewServeMux creates and returns an HTTP mux that serves all TOC routes.
func (s OSCARProxy) NewServeMux() http.Handler {
mux := http.NewServeMux()
mux.Handle("GET /info", s.RateLimiterMiddleware(s.AuthMiddleware(http.HandlerFunc(s.ProfileHandler))))
mux.Handle("GET /dir_info", s.RateLimiterMiddleware(s.AuthMiddleware(http.HandlerFunc(s.DirInfoHandler))))
mux.Handle("GET /dir_search", s.RateLimiterMiddleware(s.AuthMiddleware(http.HandlerFunc(s.DirSearchHandler))))
return mux
}
func (s OSCARProxy) RateLimiterMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
http.Error(w, "internal server error", http.StatusInternalServerError)
s.Logger.Error("failed to parse remote address", "err", err.Error())
return
}
if !s.HTTPIPRateLimiter.Allow(ip) {
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
// AuthMiddleware is an HTTP middleware that enforces authentication using an
// authorization cookie provided as a query parameter. It validates and decrypts
// the cookie before allowing the request to proceed.
//
// If the `cookie` query parameter is missing or invalid, the middleware
// responds with an appropriate HTTP error:
// - 400 Bad Request if the `cookie` parameter is missing.
// - 403 Forbidden if the cookie is invalid or cannot be decrypted.
//
// Requests with a valid cookie are passed to the next handler.
func (s OSCARProxy) AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
cookie := r.URL.Query().Get("cookie")
if cookie == "" {
http.Error(w, "required `cookie` param is missing", http.StatusBadRequest)
return
}
data, err := hex.DecodeString(cookie)
if err != nil {
s.Logger.DebugContext(ctx, "error decoding string", "err", err.Error())
http.Error(w, "invalid auth cookie", http.StatusForbidden)
return
}
if _, err = s.CookieBaker.Crack(data); err != nil {
s.Logger.DebugContext(ctx, "error cracking auth cookie", "err", err.Error())
http.Error(w, "invalid auth cookie", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// ProfileHandler handles requests to retrieve a user's profile information.
// It queries the LocateService to fetch profile data for the specified user.
//
// The request must include the following query parameters:
// - `from`: The screen name of the user making the request.
// - `user`: The screen name of the user whose profile is being requested.
//
// If any required parameter is missing, it responds with a 400 Bad Request.
// If the requested user is unavailable, it responds with a 404 Not Found.
func (s OSCARProxy) ProfileHandler(w http.ResponseWriter, r *http.Request) {
from := r.URL.Query().Get("from")
if from == "" {
http.Error(w, "required `from` param is missing", http.StatusBadRequest)
return
}
user := r.URL.Query().Get("user")
if user == "" {
http.Error(w, "required `user` param is missing", http.StatusBadRequest)
return
}
sess := s.SessionRetriever.RetrieveSession(state.NewIdentScreenName(from))
if sess == nil {
http.Error(w, "invalid session", http.StatusForbidden)
return
}
instances := sess.Instances()
if len(instances) == 0 {
http.Error(w, "invalid session", http.StatusForbidden)
return
}
inBody := wire.SNAC_0x02_0x05_LocateUserInfoQuery{
Type: uint16(wire.LocateTypeSig),
ScreenName: user,
}
ctx := r.Context()
info, err := s.LocateService.UserInfoQuery(ctx, instances[0], wire.SNACFrame{}, inBody)
if err != nil {
s.logAndReturn500(ctx, w, fmt.Errorf("LocateService.UserInfoQuery: %w", err))
return
}
switch v := info.Body.(type) {
case wire.SNACError:
if v.Code == wire.ErrorCodeNotLoggedOn {
http.Error(w, "user is unavailable", http.StatusNotFound)
} else {
s.logAndReturn500(ctx, w, fmt.Errorf("LocateService.UserInfoQuery error code: %d", v.Code))
}
case wire.SNAC_0x02_0x06_LocateUserInfoReply:
profile, hasProf := v.LocateInfo.Bytes(wire.LocateTLVTagsInfoSigData)
if !hasProf {
s.logAndReturn500(ctx, w, errors.New("LocateInfo.Bytes: missing wire.LocateTLVTagsInfoSigData"))
return
}
pd := struct {
ScreenName string
Profile template.HTML
}{
ScreenName: user,
Profile: template.HTML(extractProfile(profile)),
}
if err := profileTemplate.Execute(w, pd); err != nil {
s.logAndReturn500(ctx, w, fmt.Errorf("t.Execute: %w", err))
}
default:
s.logAndReturn500(ctx, w, fmt.Errorf("unknown response type: %T", v))
}
}
// DirInfoHandler handles requests to retrieve directory information for a user.
// It queries the LocateService to fetch directory details associated with the
// given screen name.
//
// The request must include the following query parameter:
// - `user`: The screen name of the user whose directory info is being requested.
//
// If the `user` parameter is missing, it responds with a 400 Bad Request.
// If no directory information is found, it responds with a 404 Not Found.
func (s OSCARProxy) DirInfoHandler(w http.ResponseWriter, request *http.Request) {
user := request.URL.Query().Get("user")
if user == "" {
http.Error(w, "required `user` param is missing", http.StatusBadRequest)
return
}
inBody := wire.SNAC_0x02_0x0B_LocateGetDirInfo{
ScreenName: user,
}
ctx := request.Context()
info, err := s.LocateService.DirInfo(ctx, wire.SNACFrame{}, inBody)
if err != nil {
s.logAndReturn500(ctx, w, fmt.Errorf("LocateService.DirInfo: %w", err))
return
}
switch v := info.Body.(type) {
case wire.SNAC_0x02_0x0C_LocateGetDirReply:
if len(v.TLVList) > 0 {
s.outputSearchResults(ctx, w, v.TLVBlock)
} else {
http.Error(w, "no user directory info found", http.StatusNotFound)
}
default:
s.logAndReturn500(ctx, w, fmt.Errorf("LocateService.DirInfo: unknown response type: %T", v))
}
}
// DirSearchHandler handles requests to perform a directory search based on
// various criteria. It queries the DirSearchService to find users matching the
// specified parameters. There are 3 search modes: name, email, keyword.
//
// -Named-based search is toggled by the presence of either `first_name`
// and/or `last_name` params. The following search params can be passed:
// -`first_name`
// -`middle_name`
// -`last_name`
// -`maiden_name`
// -`city`
// -`state`
// -`country`
// -Email-based search is triggered by the`email` param.
// -Keyword-based search is triggered by the `keyword` param.
//
// If the search is missing required name parameters, it responds with a 400
// Bad Request.
func (s OSCARProxy) DirSearchHandler(w http.ResponseWriter, r *http.Request) {
inBody := wire.SNAC_0x0F_0x02_InfoQuery{}
q := r.URL.Query()
switch {
case q.Has("first_name") || q.Has("last_name"):
if val := q.Get("first_name"); val != "" {
inBody.Append(wire.NewTLVBE(wire.ODirTLVFirstName, val))
}
if val := q.Get("middle_name"); val != "" {
inBody.Append(wire.NewTLVBE(wire.ODirTLVMiddleName, val))
}
if val := q.Get("last_name"); val != "" {
inBody.Append(wire.NewTLVBE(wire.ODirTLVLastName, val))
}
if val := q.Get("maiden_name"); val != "" {
inBody.Append(wire.NewTLVBE(wire.ODirTLVMaidenName, val))
}
if val := q.Get("city"); val != "" {
inBody.Append(wire.NewTLVBE(wire.ODirTLVCity, val))
}
if val := q.Get("state"); val != "" {
inBody.Append(wire.NewTLVBE(wire.ODirTLVState, val))
}
if val := q.Get("country"); val != "" {
inBody.Append(wire.NewTLVBE(wire.ODirTLVCountry, val))
}
case q.Has("email"):
inBody.Append(wire.NewTLVBE(wire.ODirTLVEmailAddress, q.Get("email")))
case q.Has("keyword"):
inBody.Append(wire.NewTLVBE(wire.ODirTLVInterest, q.Get("keyword")))
}
ctx := r.Context()
info, err := s.DirSearchService.InfoQuery(ctx, wire.SNACFrame{}, inBody)
if err != nil {
s.logAndReturn500(ctx, w, fmt.Errorf("DirSearchService.InfoQuery: %w", err))
return
}
switch v := info.Body.(type) {
case wire.SNAC_0x0F_0x03_InfoReply:
switch v.Status {
case wire.ODirSearchResponseNameMissing:
http.Error(w, "missing search parameters", http.StatusBadRequest)
case wire.ODirSearchResponseOK:
s.outputSearchResults(ctx, w, v.Results.List...)
default:
s.logAndReturn500(ctx, w, fmt.Errorf("DirSearchService.InfoQuery unknown status: %d", v.Status))
}
default:
s.logAndReturn500(ctx, w, fmt.Errorf("DirSearchService.InfoQuery: unknown response type: %T", v))
}
}
func (s OSCARProxy) outputSearchResults(ctx context.Context, w http.ResponseWriter, users ...wire.TLVBlock) {
type DirSearchResult struct {
FirstName string
MiddleName string
LastName string
MaidenName string
Country string
State string
City string
NickName string
ZIP string
Address string
ScreenName string
}
type PageData struct {
Results []DirSearchResult
}
results := make([]DirSearchResult, 0, len(users))
for _, result := range users {
rec := DirSearchResult{}
rec.ScreenName, _ = result.String(wire.ODirTLVScreenName)
rec.FirstName, _ = result.String(wire.ODirTLVFirstName)
rec.MiddleName, _ = result.String(wire.ODirTLVMiddleName)
rec.LastName, _ = result.String(wire.ODirTLVLastName)
rec.MaidenName, _ = result.String(wire.ODirTLVMaidenName)
rec.Country, _ = result.String(wire.ODirTLVCountry)
rec.State, _ = result.String(wire.ODirTLVState)
rec.City, _ = result.String(wire.ODirTLVCity)
rec.NickName, _ = result.String(wire.ODirTLVNickName)
rec.ZIP, _ = result.String(wire.ODirTLVZIP)
rec.Address, _ = result.String(wire.ODirTLVAddress)
results = append(results, rec)
}
if err := directoryTemplate.Execute(w, PageData{Results: results}); err != nil {
s.logAndReturn500(ctx, w, fmt.Errorf("t.Execute: %w", err))
}
}
func (s OSCARProxy) logAndReturn500(ctx context.Context, w http.ResponseWriter, err error) {
s.Logger.ErrorContext(ctx, "internal service error", "err", err.Error())
http.Error(w, "internal server error", http.StatusInternalServerError)
}
// extractProfile extracts the contents of an HTML . If there's no HTML
// body, just return the text.
//
// It only returns the following HTML tags:
func extractProfile(htmlContent []byte) string {
tokenizer := html.NewTokenizer(bytes.NewReader(htmlContent))
var bodyContent bytes.Buffer
for {
switch tokenizer.Next() {
case html.ErrorToken:
if err := tokenizer.Err(); err != nil && err != io.EOF {
return "unable to read profile"
}
return bodyContent.String()
case html.StartTagToken, html.EndTagToken:
token := tokenizer.Token()
switch token.Data {
case "b", "i", "font", "a", "u", "br", "hr", "s", "sub", "sup":
bodyContent.WriteString(token.String())
}
case html.TextToken:
bodyContent.Write(tokenizer.Text())
}
}
}