webhook

package
v2.2.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 26, 2026 License: Apache-2.0 Imports: 22 Imported by: 0

Documentation

Index

Constants

View Source
const (
	PathWebhook                = "/webhook/iris"
	HeaderIrisMessageID        = irishmac.HeaderIrisMessageID
	HeaderIrisRoute            = "X-Iris-Route"
	HeaderIrisSignatureVersion = irishmac.HeaderIrisSignatureVersion

	SignatureVersionV3 = irishmac.SignatureVersionV3

	HeaderIrisTimestamp  = irishmac.HeaderIrisTimestamp
	HeaderIrisNonce      = irishmac.HeaderIrisNonce
	HeaderIrisSignature  = irishmac.HeaderIrisSignature
	HeaderIrisBodySHA256 = irishmac.HeaderIrisBodySHA256
)
View Source
const (
	MessageTypeText        = "1"
	EventTypeKakaoFeed     = "kakao_feed"
	KakaoFeedSchemaVersion = 1
)
View Source
const (
	KakaoFeedStatusRecognized  = "recognized"
	KakaoFeedStatusUnknown     = "unknown"
	KakaoFeedStatusSchemaError = "schema_error"
	KakaoFeedStatusDecodeError = "decode_error"
)
View Source
const (
	KakaoFeedKindUserJoined         = "user_joined"
	KakaoFeedKindUserLeft           = "user_left"
	KakaoFeedKindOpenLinkUserJoined = "open_link_user_joined"
	KakaoFeedKindOpenLinkDeleted    = "open_link_deleted"
	KakaoFeedKindUserKicked         = "user_kicked"
	KakaoFeedKindModeratorAdded     = "moderator_added"
	KakaoFeedKindModeratorRemoved   = "moderator_removed"
	KakaoFeedKindMessageDeleted     = "message_deleted"
	KakaoFeedKindHostChanged        = "host_changed"
	KakaoFeedKindMessageChanged     = "message_changed"
	KakaoFeedKindMessageHidden      = "message_hidden"
	KakaoFeedKindManagerSpeakerMode = "manager_speaker_mode"
	KakaoFeedKindUnknown            = "unknown"
)
View Source
const (
	MaxMessageRoutes          = 128
	MaxMessageRoutePredicates = 16
)
View Source
const DefaultDedupTTL = 16 * time.Minute

DefaultDedupTTL은 확정(commit)된 키의 TTL입니다. 이 값이 발신자의 마지막 재전송보다 먼저 만료되면 이미 처리한 메시지가 다시 처리되므로, WithDedupTTL로 낮출 때 그 도달 시각을 함께 확인하십시오.

Variables

View Source
var (
	ErrMessageAdmitterRequired = errors.New("webhook: message admitter is required")
	ErrNonceStoreRequired      = errors.New("webhook: explicit set-once nonce store is required")
)
View Source
var (
	ErrMessageRouteHandlerRequired   = errors.New("webhook: message route handler is required")
	ErrMessageRoutePredicateRequired = errors.New("webhook: message route predicate is required")
	ErrTooManyMessageRoutes          = errors.New("webhook: too many message routes")
	ErrTooManyMessageRoutePredicates = errors.New("webhook: too many message route predicates")
)
View Source
var ErrAlreadyAdmitted = errors.New("webhook: message already admitted")

ErrAlreadyAdmitted는 MessageAdmitter가 이미 admit된 메시지의 재수신을 알리는 sentinel이다. Handler는 이를 실패가 아닌 중복 수리로 간주해 duplicate 계측(Metrics.ObserveDuplicate)을 관측하고 200 OK를 반환한다.

View Source
var ErrDedupReservationLost = errors.New("webhook: dedup reservation lost")

ErrDedupReservationLost는 token이 더 이상 해당 키의 예약을 소유하지 않을 때 Commit/ReleaseReservation이 반환합니다. 예약 TTL 만료 후 다른 요청이 같은 키를 재예약했거나 예약이 이미 정리된 경우입니다.

Functions

func DedupKey

func DedupKey(messageID string) string

DedupKey는 주어진 메시지 ID로 중복 제거 키를 생성합니다. MessageID가 비어 있으면 빈 문자열을 반환합니다.

func MatchText

func MatchText(ctx MessageContext) bool

func ResolveThreadID

func ResolveThreadID(req *WebhookRequest) string

func StripeKey added in v2.1.2

func StripeKey(msg *Message) string

StripeKey는 scheduler 경로의 직렬화 키를 반환한다. Room과 JSON.ThreadID는 각각 공백을 제거해 평가하며, 둘 다 비어 있지 않을 때만 "Room:ThreadID" 키가 되고 ThreadID가 없거나 공백뿐이면 Room 단독 키로 내려간다. OrderingModeKey에서 같은 키의 메시지는 도착 순서대로 한 번에 하나씩 dispatch되고 서로 다른 키는 병행하며, 빈 키(nil 메시지 또는 빈 Room)도 하나의 직렬화 lane을 공유한다.

Types

type ContextHandlerFunc

type ContextHandlerFunc func(context.Context, MessageContext)

type DedupPendingObserver

type DedupPendingObserver interface {
	ObserveDedupPendingRejected()
}

DedupPendingObserver는 확정 전 예약 때문에 503으로 되돌린 요청을 Metrics 구현이 직접 계상하도록 선언하는 선택적 마커입니다. WithMetrics로 주입한 값이 이 메서드를 가지면 Handler가 pending 거절마다 호출합니다. Metrics 인터페이스 자체는 바뀌지 않으므로 기존 구현은 그대로 컴파일되고, 구현하지 않아도 Handler.DedupPendingRejectedCount에서 누적값을 읽을 수 있습니다.

type DedupState

type DedupState int
const (
	DedupStateReserved DedupState = iota
	// 다른 요청이 예약을 쥔 채 아직 commit하지 않은 상태다.
	DedupStatePending
	DedupStateCommitted
)

type Handler

type Handler struct {
	// contains filtered or unexported fields
}

Handler는 stripe 워커 풀을 갖춘 webhook HTTP 핸들러입니다.

func NewDurableHandler

func NewDurableHandler(
	ctx context.Context,
	token string,
	admitter MessageAdmitter,
	logger *slog.Logger,
	opts ...HandlerOption,
) (*Handler, error)

NewDurableHandler는 MessageHandler 없이 durable admission 전용 Handler를 구성한다. 처리(dispatch)는 소비자의 inbox 루프가 소유한다.

func NewHandler

func NewHandler(
	ctx context.Context,
	token string,
	handler MessageHandler,
	logger *slog.Logger,
	opts ...HandlerOption,
) (*Handler, error)

func (*Handler) Close

func (h *Handler) Close() error

Close는 admission을 닫고 모든 작업이 끝날 때까지 기다리는 호환 wrapper입니다.

func (*Handler) CloseContext

func (h *Handler) CloseContext(ctx context.Context) error

CloseContext는 grace context가 끝나면 queued callback을 건너뛰고 in-flight context를 취소한다.

func (*Handler) DedupPendingRejectedCount

func (h *Handler) DedupPendingRejectedCount() uint64

DedupPendingRejectedCount는 확정 전 예약 때문에 503으로 되돌린 요청 수를 반환합니다.

func (*Handler) Diagnostics

func (h *Handler) Diagnostics() ReceiveDiagnostics

func (*Handler) NonceStoreUnavailableCount

func (h *Handler) NonceStoreUnavailableCount() uint64

NonceStoreUnavailableCount는 HMAC nonce 저장소 조회 실패(오류·타임아웃) 때문에 503으로 되돌린 요청 수를 반환합니다.

func (*Handler) ServeHTTP

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP는 Iris webhook 요청을 처리합니다.

func (*Handler) SignatureVersionDiagnostics

func (h *Handler) SignatureVersionDiagnostics() SignatureVersionDiagnostics

SignatureVersionDiagnostics는 signature compare 성공과 version 거절 누적값을 반환합니다.

type HandlerOption

type HandlerOption func(*Handler)

func WithAdmitTimeout

func WithAdmitTimeout(d time.Duration) HandlerOption

WithAdmitTimeout은 0 이하를 "무제한"이 아니라 기본값 30초로 정규화합니다.

func WithContext

func WithContext(ctx context.Context) HandlerOption

func WithDedupPendingTTL

func WithDedupPendingTTL(d time.Duration) HandlerOption

WithDedupPendingTTL은 MessageDeduplicator 예약(pending)의 수명을 지정합니다. 확정(commit)된 키는 WithDedupTTL을 따르며, 이 값은 예약 후 확정 전에 프로세스가 죽었을 때 키가 pending으로 묶여 있는 최대 시간입니다. 그동안 재전송은 503을 받으므로 `DedupPendingTTL + 여유 < 발신자에게 남은 재시도 예산`이 성립해야 재전송이 유실되지 않습니다.

func WithDedupTTL

func WithDedupTTL(d time.Duration) HandlerOption

func WithDedupTimeout

func WithDedupTimeout(d time.Duration) HandlerOption

WithDedupTimeout은 dedup/nonce 저장소 왕복 하나의 상한입니다. 0 이하는 무제한이 아니라 기본값으로 정규화됩니다.

func WithDurableAdmission

func WithDurableAdmission(admitter MessageAdmitter) HandlerOption

WithDurableAdmission은 in-memory scheduler 대신 동기 durable admission을 사용한다.

func WithEnqueueTimeout

func WithEnqueueTimeout(d time.Duration) HandlerOption

func WithHandlerTimeout

func WithHandlerTimeout(d time.Duration) HandlerOption

func WithMaxBodyBytes

func WithMaxBodyBytes(n int64) HandlerOption

func WithMessageDeduplicator

func WithMessageDeduplicator(d MessageDeduplicator) HandlerOption

func WithMetrics

func WithMetrics(m Metrics) HandlerOption

func WithNonceStore

func WithNonceStore(store NonceStore) HandlerOption

WithNonceStore는 HMAC replay 방지용 set-once 저장소를 지정합니다.

func WithOrderingMode

func WithOrderingMode(mode OrderingMode) HandlerOption

func WithQueueSize

func WithQueueSize(n int) HandlerOption

func WithReplayWindow

func WithReplayWindow(d time.Duration) HandlerOption

func WithTaskPool

func WithTaskPool(pool TaskPool) HandlerOption

func WithWebhookLogger

func WithWebhookLogger(logger *slog.Logger) HandlerOption

func WithWebhookSecret

func WithWebhookSecret(secret string) HandlerOption

func WithWebhookToken

func WithWebhookToken(token string) HandlerOption

func WithWorkerCount

func WithWorkerCount(n int) HandlerOption

type HandlerOptions

type HandlerOptions struct {
	WorkerCount    int
	QueueSize      int
	EnqueueTimeout time.Duration
	AdmitTimeout   time.Duration
	HandlerTimeout time.Duration
	OrderingMode   OrderingMode
	DedupTTL       time.Duration
	DedupTimeout   time.Duration
	MaxBodyBytes   int64
}

type Message

type Message struct {
	Msg    string       `json:"msg"`
	Room   string       `json:"room"`
	Sender *string      `json:"sender,omitempty"`
	JSON   *MessageJSON `json:"json,omitempty"`
}

type MessageAdmitter

type MessageAdmitter interface {
	AdmitMessage(ctx context.Context, msg *Message) error
}

MessageAdmitter는 HTTP 200 전에 메시지를 durable store에 commit하는 계약이다. 구현은 이미 admit된 메시지를 감지하면 ErrAlreadyAdmitted(래핑 포함, errors.Is로 판정)를 반환할 수 있다. 그 외 모든 오류는 503으로 매핑되어 Iris sender의 재전송을 유도한다.

type MessageContext

type MessageContext struct {
	// contains filtered or unexported fields
}

MessageContext는 webhook.Message의 정규화된 immutable snapshot입니다.

func NewMessageContext

func NewMessageContext(message *Message) MessageContext

func (MessageContext) Attachment

func (c MessageContext) Attachment() string

func (MessageContext) ChatLogID

func (c MessageContext) ChatLogID() string

func (MessageContext) EventKind

func (c MessageContext) EventKind() string

func (MessageContext) EventPayload

func (c MessageContext) EventPayload() jsonv1.RawMessage

func (MessageContext) EventSchemaVersion

func (c MessageContext) EventSchemaVersion() (int, bool)

func (MessageContext) EventStatus

func (c MessageContext) EventStatus() string

func (MessageContext) EventType

func (c MessageContext) EventType() string

func (MessageContext) IsMine

func (c MessageContext) IsMine() (bool, bool)

func (MessageContext) IsText

func (c MessageContext) IsText() bool

func (MessageContext) Mentions

func (c MessageContext) Mentions() []WebhookMention

func (MessageContext) MessageID

func (c MessageContext) MessageID() string

func (MessageContext) MessageType

func (c MessageContext) MessageType() string

func (MessageContext) Origin

func (c MessageContext) Origin() string

func (MessageContext) RawSourceLogID

func (c MessageContext) RawSourceLogID() (int64, bool)

func (MessageContext) RoomID

func (c MessageContext) RoomID() string

func (MessageContext) RoomLinkID

func (c MessageContext) RoomLinkID() string

func (MessageContext) RoomType

func (c MessageContext) RoomType() string

func (MessageContext) Route

func (c MessageContext) Route() string

func (MessageContext) Sender

func (c MessageContext) Sender() string

func (MessageContext) SourceAccountID

func (c MessageContext) SourceAccountID() string

func (MessageContext) SourceGenerationID

func (c MessageContext) SourceGenerationID() (int64, bool)

func (MessageContext) SourceLogID

func (c MessageContext) SourceLogID() (int64, bool)

func (MessageContext) StableMessageIdentity

func (c MessageContext) StableMessageIdentity() string

StableMessageIdentity의 반환 format은 아직 안정 계약이 아니며 v1.x 내에서 변경될 수 있습니다.

func (MessageContext) Text

func (c MessageContext) Text() string

func (MessageContext) ThreadID

func (c MessageContext) ThreadID() string

func (MessageContext) ThreadScope

func (c MessageContext) ThreadScope() (int, bool)

func (MessageContext) UserID

func (c MessageContext) UserID() string

type MessageDeduplicator

type MessageDeduplicator interface {
	Reserve(ctx context.Context, key string, ttl time.Duration) (string, DedupState, error)
	Commit(ctx context.Context, key, token string, ttl time.Duration) error
	ReleaseReservation(ctx context.Context, key, token string) error
}

MessageDeduplicator는 예약과 admission 확정을 분리하는 token-bound message dedup 계약입니다. WithMessageDeduplicator로 주입하면 Handler는 다음 순서를 사용합니다.

  • Reserve가 DedupStateReserved를 반환하면 요청을 계속 처리합니다.
  • enqueue가 성공하면 Commit으로 admission을 확정하고 HTTP 200을 반환합니다.
  • enqueue가 실패하면 ReleaseReservation으로 자신의 token이 쥔 예약만 해제하고 HTTP 503을 반환하므로 재전송이 다시 admission될 수 있습니다.
  • Reserve가 DedupStatePending을 반환하면(선행 요청이 아직 확정 전) HTTP 503을 반환해 재전송이 나중에 처리되게 합니다.
  • Reserve가 DedupStateCommitted를 반환하면 HTTP 200으로 흡수합니다.

Reserve가 반환하는 token은 Handler에게 불투명한 값이며 Commit/ReleaseReservation에 그대로 전달됩니다. 다른 token이 쥔 예약은 절대 삭제하거나 덮어써서는 안 되고, 그 경우 ErrDedupReservationLost를 반환해야 합니다.

Reserve가 오류를 반환할 때는, 예약이 저장소에 존재할 수 없음을 증명할 수 있을 때만 빈 token을 반환하십시오. 그 외에는 시도에 쓴 token을 오류와 함께 반환해야 하며, Handler는 그 token으로 ReleaseReservation을 시도한 뒤 요청을 503으로 거절합니다. 명령을 보내기 전에 만료된 context는 그 증명입니다. 저장소가 낸 오류는 예약 쓰기가 단일 왕복의 마지막 변경 단계인 backend에서만 증명이 됩니다 — 쓰기 뒤에 다른 왕복이나 변경이 남아 있으면 그 오류는 이미 저장된 예약을 부정하지 못합니다.

type MessageHandler

type MessageHandler interface {
	HandleMessage(ctx context.Context, msg *Message)
}

MessageHandler는 수신된 webhook 메시지를 처리하는 인터페이스입니다.

type MessageHandlerFunc

type MessageHandlerFunc func(context.Context, *Message)

func (MessageHandlerFunc) HandleMessage

func (f MessageHandlerFunc) HandleMessage(ctx context.Context, message *Message)

type MessageJSON

type MessageJSON struct {
	UserID             string            `json:"user_id,omitempty"`
	Message            string            `json:"message,omitempty"`
	ChatID             string            `json:"chat_id,omitempty"`
	Type               string            `json:"type,omitempty"`
	Route              string            `json:"route,omitempty"`
	MessageID          string            `json:"message_id,omitempty"`
	ChatLogID          string            `json:"chat_log_id,omitempty"`
	RoomType           string            `json:"room_type,omitempty"`
	RoomLinkID         string            `json:"room_link_id,omitempty"`
	SourceLogID        *int64            `json:"source_log_id,omitempty"`
	RawSourceLogID     *int64            `json:"raw_source_log_id,omitempty"`
	SourceGenerationID *int64            `json:"source_generation_id,omitempty"`
	SourceAccountID    string            `json:"source_account_id,omitempty"`
	ThreadID           *string           `json:"thread_id,omitempty"`
	ThreadScope        *int              `json:"thread_scope,omitempty"`
	IsMine             *bool             `json:"is_mine,omitempty"`
	Origin             string            `json:"origin,omitempty"`
	Attachment         string            `json:"attachment,omitempty"`
	Mentions           []WebhookMention  `json:"mentions,omitempty"`
	EventPayload       jsonv1.RawMessage `json:"event_payload,omitempty"`
}

type MessagePredicate

type MessagePredicate func(MessageContext) bool

func MatchEventKind

func MatchEventKind(eventKinds ...string) MessagePredicate

func MatchEventStatus

func MatchEventStatus(eventStatuses ...string) MessagePredicate

func MatchEventType

func MatchEventType(eventTypes ...string) MessagePredicate

func MatchMessageType

func MatchMessageType(messageTypes ...string) MessagePredicate

func MatchRoute

func MatchRoute(routes ...string) MessagePredicate

type MessageRoute

type MessageRoute struct {
	// contains filtered or unexported fields
}

func Route

func Route(handler ContextHandlerFunc, predicates ...MessagePredicate) MessageRoute

type Metrics

type Metrics interface {
	ObserveRequest()
	ObserveUnauthorized()
	ObserveBadRequest()
	ObserveDuplicate()
	ObserveEnqueueFailure()
	ObserveAccepted()
	ObserveDecodeLatency(d time.Duration)
	ObserveDedupLatency(d time.Duration)
	ObserveEnqueueWait(d time.Duration)
	ObserveQueueDepth(depth int)
	ObserveHandlerDuration(d time.Duration)
}

Metrics는 webhook 핸들러 메트릭 관측 포인트를 정의합니다.

type NonceStore

type NonceStore interface {
	IsDuplicate(ctx context.Context, key string, ttl time.Duration) (bool, error)
}

NonceStore는 HMAC replay 방지용 nonce 저장소입니다. IsDuplicate는 최초 관측이면 키를 기록하고, 기록에 실패하면 오류를 반환해야 합니다(fail-closed). 이 역할은 message dedup의 상태 계약과 무관하게 유지됩니다.

type NoopMetrics

type NoopMetrics struct{}

func (NoopMetrics) ObserveAccepted

func (NoopMetrics) ObserveAccepted()

func (NoopMetrics) ObserveBadRequest

func (NoopMetrics) ObserveBadRequest()

func (NoopMetrics) ObserveDecodeLatency

func (NoopMetrics) ObserveDecodeLatency(_ time.Duration)

func (NoopMetrics) ObserveDedupLatency

func (NoopMetrics) ObserveDedupLatency(_ time.Duration)

func (NoopMetrics) ObserveDuplicate

func (NoopMetrics) ObserveDuplicate()

func (NoopMetrics) ObserveEnqueueFailure

func (NoopMetrics) ObserveEnqueueFailure()

func (NoopMetrics) ObserveEnqueueWait

func (NoopMetrics) ObserveEnqueueWait(_ time.Duration)

func (NoopMetrics) ObserveHandlerDuration

func (NoopMetrics) ObserveHandlerDuration(_ time.Duration)

func (NoopMetrics) ObserveQueueDepth

func (NoopMetrics) ObserveQueueDepth(_ int)

func (NoopMetrics) ObserveRequest

func (NoopMetrics) ObserveRequest()

func (NoopMetrics) ObserveUnauthorized

func (NoopMetrics) ObserveUnauthorized()

type OrderingMode

type OrderingMode int
const (
	OrderingModeKey OrderingMode = iota
	OrderingModeNone
)

type ReceiveDiagnostics

type ReceiveDiagnostics struct {
	SchedulerEnabled  bool   `json:"schedulerEnabled"`
	WorkersConfigured int    `json:"workersConfigured,omitempty,omitzero"`
	QueueSize         int    `json:"queueSize,omitempty,omitzero"`
	Pending           int    `json:"pending,omitempty,omitzero"`
	InFlight          int    `json:"inFlight,omitempty,omitzero"`
	EnqueueRejected   uint64 `json:"enqueueRejected"`
	QueueFullCount    uint64 `json:"queueFullCount"`
	HandlerTimeouts   uint64 `json:"handlerTimeoutCount"`
}

type Router

type Router struct {
	// contains filtered or unexported fields
}

func NewRouter

func NewRouter(routes []MessageRoute, fallback ContextHandlerFunc) (*Router, error)

func (*Router) HandleMessage

func (r *Router) HandleMessage(ctx context.Context, message *Message)

type SDKConfig

type SDKConfig struct {
	Token  string
	Secret string
	Logger *slog.Logger
	Ctx    context.Context //nolint:containedctx // WithContext 옵션 값을 그대로 노출하는 SDK 설정 스냅샷이다.
}

func ResolveSDKConfig

func ResolveSDKConfig(opts []HandlerOption) SDKConfig

type SetOnceNonceStore

type SetOnceNonceStore interface {
	NonceStore

	SetOnceNonce()
}

SetOnceNonceStore는 IsDuplicate가 set-once fail-closed임을 backend가 선언하는 마커입니다. IsDuplicate가 키를 원자적으로 한 번만 기록하고, 기록에 실패하면 오류를 반환하는 backend만 이 마커를 구현해야 합니다. Handler는 이 마커가 없는 store를 거절합니다.

type SignatureVersionDiagnostics

type SignatureVersionDiagnostics struct {
	V3Validated       uint64 `json:"v3Validated"`
	UnknownRejected   uint64 `json:"unknownRejected"`
	MalformedRejected uint64 `json:"malformedRejected"`
}

SignatureVersionDiagnostics는 HMAC 검증 version의 고정 cardinality 누적값입니다.

type TaskPool

type TaskPool interface {
	SubmitWait(task func()) bool
}

type WebhookMention

type WebhookMention struct {
	UserID   string `json:"userId"`
	Nickname string `json:"nickname,omitempty"`
	At       []int  `json:"at,omitempty"`
	Len      int    `json:"len,omitempty,omitzero"`
}

func (*WebhookMention) UnmarshalJSON

func (m *WebhookMention) UnmarshalJSON(data []byte) error

type WebhookRequest

type WebhookRequest struct {
	Route              string            `json:"route,omitempty"`
	MessageID          string            `json:"messageId,omitempty"`
	SourceLogID        int64             `json:"sourceLogId,omitempty,omitzero"`
	RawSourceLogID     *int64            `json:"rawSourceLogId,omitempty"`
	SourceGenerationID *int64            `json:"sourceGenerationId,omitempty"`
	SourceAccountID    string            `json:"sourceAccountId,omitempty"`
	Text               string            `json:"text"`
	Room               string            `json:"room"`
	Sender             string            `json:"sender"`
	UserID             string            `json:"userId"`
	ChatLogID          string            `json:"chatLogId,omitempty"`
	RoomType           string            `json:"roomType,omitempty"`
	RoomLinkID         string            `json:"roomLinkId,omitempty"`
	ThreadID           string            `json:"threadId,omitempty"`
	ThreadScope        *int              `json:"threadScope,omitempty"`
	Type               string            `json:"type,omitempty"`
	IsMine             *bool             `json:"isMine,omitempty"`
	Origin             string            `json:"origin,omitempty"`
	Attachment         string            `json:"attachment,omitempty"`
	Mentions           []WebhookMention  `json:"mentions,omitempty"`
	EventPayload       jsonv1.RawMessage `json:"eventPayload,omitempty"`
}

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL