223 lines
5.6 KiB
Go
223 lines
5.6 KiB
Go
package http
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"time"
|
|
|
|
"golang.org/x/text/language"
|
|
|
|
"source.toby3d.me/toby3d/hub/internal/common"
|
|
"source.toby3d.me/toby3d/hub/internal/domain"
|
|
"source.toby3d.me/toby3d/hub/internal/hub"
|
|
"source.toby3d.me/toby3d/hub/internal/subscription"
|
|
"source.toby3d.me/toby3d/hub/internal/topic"
|
|
"source.toby3d.me/toby3d/hub/web/template"
|
|
)
|
|
|
|
type (
|
|
Request struct {
|
|
Callback *url.URL
|
|
Topic *url.URL
|
|
Secret domain.Secret
|
|
Mode domain.Mode
|
|
LeaseSeconds float64
|
|
}
|
|
|
|
Response struct {
|
|
Mode domain.Mode
|
|
Reason string
|
|
Topic domain.Topic
|
|
}
|
|
|
|
NewHandlerParams struct {
|
|
Hub hub.UseCase
|
|
Subscriptions subscription.UseCase
|
|
Topics topic.UseCase
|
|
Matcher language.Matcher
|
|
Name string
|
|
}
|
|
|
|
Handler struct {
|
|
hub hub.UseCase
|
|
subscriptions subscription.UseCase
|
|
topics topic.UseCase
|
|
matcher language.Matcher
|
|
name string
|
|
}
|
|
)
|
|
|
|
var DefaultRequestLeaseSeconds = time.Duration(10 * 24 * time.Hour).Seconds() // 10 days
|
|
|
|
var (
|
|
ErrHubMode = errors.New(common.HubMode + " MUST be " + domain.ModeSubscribe.String() + " or " +
|
|
domain.ModeUnsubscribe.String())
|
|
ErrHubSecret = errors.New(common.HubSecret + " SHOULD be specified when the request was made over HTTPS")
|
|
)
|
|
|
|
func NewHandler(params NewHandlerParams) *Handler {
|
|
return &Handler{
|
|
hub: params.Hub,
|
|
matcher: params.Matcher,
|
|
name: params.Name,
|
|
subscriptions: params.Subscriptions,
|
|
topics: params.Topics,
|
|
}
|
|
}
|
|
|
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
now := time.Now().UTC().Round(time.Second)
|
|
|
|
switch r.Method {
|
|
default:
|
|
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
|
|
case http.MethodPost:
|
|
req := NewRequest()
|
|
|
|
var err error
|
|
if err = req.bind(r); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
// TODO(toby3d): send denied ping to callback if it's not accepted by hub
|
|
|
|
s := new(domain.Subscription)
|
|
req.populate(s, now)
|
|
|
|
switch req.Mode {
|
|
case domain.ModeSubscribe, domain.ModeUnsubscribe:
|
|
if _, err = h.hub.Verify(r.Context(), *s, req.Mode); err != nil {
|
|
r.Clone(context.WithValue(r.Context(), "error", err))
|
|
|
|
w.WriteHeader(http.StatusAccepted)
|
|
|
|
return
|
|
}
|
|
|
|
switch req.Mode {
|
|
case domain.ModeSubscribe:
|
|
_, err = h.subscriptions.Subscribe(r.Context(), *s)
|
|
case domain.ModeUnsubscribe:
|
|
_, err = h.subscriptions.Unsubscribe(r.Context(), *s)
|
|
}
|
|
case domain.ModePublish:
|
|
_, err = h.topics.Publish(r.Context(), req.Topic)
|
|
}
|
|
|
|
if err != nil {
|
|
r.Clone(context.WithValue(r.Context(), "error", err))
|
|
}
|
|
|
|
w.WriteHeader(http.StatusAccepted)
|
|
case "", http.MethodGet:
|
|
tags, _, _ := language.ParseAcceptLanguage(r.Header.Get(common.HeaderAcceptLanguage))
|
|
tag, _, _ := h.matcher.Match(tags...)
|
|
|
|
w.Header().Set(common.HeaderContentType, common.MIMETextHTMLCharsetUTF8)
|
|
template.WriteTemplate(w, &template.Home{BaseOf: template.NewBaseOf(tag, h.name)})
|
|
}
|
|
}
|
|
|
|
func NewRequest() *Request {
|
|
return &Request{
|
|
Mode: domain.ModeUnd,
|
|
Callback: nil,
|
|
Secret: domain.Secret{},
|
|
Topic: nil,
|
|
LeaseSeconds: DefaultRequestLeaseSeconds,
|
|
}
|
|
}
|
|
|
|
func (r *Request) bind(req *http.Request) error {
|
|
var err error
|
|
if err = req.ParseForm(); err != nil {
|
|
return fmt.Errorf("cannot parse request form: %w", err)
|
|
}
|
|
|
|
if !req.PostForm.Has(common.HubMode) {
|
|
return fmt.Errorf("%s parameter is required, but not provided", common.HubMode)
|
|
}
|
|
|
|
// NOTE(toby3d): hub.mode
|
|
if r.Mode, err = domain.ParseMode(req.PostForm.Get(common.HubMode)); err != nil {
|
|
return fmt.Errorf("cannot parse %s: %w", common.HubMode, err)
|
|
}
|
|
|
|
// NOTE(toby3d): hub.topic
|
|
if !req.PostForm.Has(common.HubTopic) {
|
|
return fmt.Errorf("%s parameter is required, but not provided", common.HubTopic)
|
|
}
|
|
|
|
if r.Topic, err = url.Parse(req.PostForm.Get(common.HubTopic)); err != nil {
|
|
return fmt.Errorf("cannot parse %s: %w", common.HubTopic, err)
|
|
}
|
|
|
|
switch r.Mode {
|
|
case domain.ModePublish:
|
|
case domain.ModeSubscribe, domain.ModeUnsubscribe:
|
|
// NOTE(toby3d): hub.callback
|
|
if !req.PostForm.Has(common.HubCallback) {
|
|
return fmt.Errorf("%s parameter is required, but not provided", common.HubCallback)
|
|
}
|
|
|
|
if r.Callback, err = url.Parse(req.PostForm.Get(common.HubCallback)); err != nil {
|
|
return fmt.Errorf("cannot parse %s: %w", common.HubCallback, err)
|
|
}
|
|
|
|
// NOTE(toby3d): hub.lease_seconds
|
|
if r.Mode != domain.ModeUnsubscribe && req.PostForm.Has(common.HubLeaseSeconds) {
|
|
r.LeaseSeconds, err = strconv.ParseFloat(req.PostForm.Get(common.HubLeaseSeconds), 64)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot parse %s: %w", common.HubLeaseSeconds, err)
|
|
}
|
|
}
|
|
|
|
// NOTE(toby3d): hub.secret
|
|
if !req.PostForm.Has(common.HubSecret) {
|
|
if req.TLS != nil {
|
|
return ErrHubSecret
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
secret, err := domain.ParseSecret(req.PostForm.Get(common.HubSecret))
|
|
if err != nil {
|
|
return fmt.Errorf("cannot parse %s: %w", common.HubSecret, err)
|
|
}
|
|
|
|
r.Secret = *secret
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r Request) populate(s *domain.Subscription, ts time.Time) {
|
|
s.CreatedAt = ts
|
|
s.UpdatedAt = ts
|
|
s.ExpiredAt = ts.Add(time.Duration(r.LeaseSeconds) * time.Second).Round(time.Second)
|
|
s.Callback = r.Callback
|
|
s.Topic = r.Topic
|
|
s.Secret = r.Secret
|
|
}
|
|
|
|
func NewResponse(t domain.Topic, err error) *Response {
|
|
return &Response{
|
|
Mode: domain.ModeDenied,
|
|
Topic: t,
|
|
Reason: err.Error(),
|
|
}
|
|
}
|
|
|
|
func (r *Response) populate(q url.Values) {
|
|
r.Mode.AddQuery(q)
|
|
r.Topic.AddQuery(q)
|
|
q.Add(common.HubReason, r.Reason)
|
|
}
|