Implements collectible usernames, official verification workflows, and third-party bot verification after maintainer protocol and migration review. The composite activity/moderation rating remains an admin-only read model; Telegram Stars Rating wire fields stay unset pending a dedicated official-semantics implementation. Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9 Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b Co-authored-by: Egor Egorov <business.egor.sg@gmail.com>
1422 lines
52 KiB
Go
1422 lines
52 KiB
Go
// Package verification implements the official platform verification use cases:
|
|
// the applicant flow filed through the built-in @verifybot, the reviewer flow
|
|
// driven from the admin panel, and the applicant-notification delivery cycle.
|
|
//
|
|
// This is the platform badge (user#b1b8cc83 verified / channel#d49f34c6
|
|
// verified), never the third-party botVerification mechanism. An approval here
|
|
// flips exactly one flag on one peer record.
|
|
//
|
|
// Everything that decides whether a target may carry the badge lives in this
|
|
// package, and it is evaluated twice: once when the application is filed and
|
|
// again, against a freshly loaded snapshot, at the moment of approval. A target
|
|
// that turned scam, lost its username, was frozen or was verified by another
|
|
// route between submission and review is refused at the second gate, so the
|
|
// review queue can never be used to launder a state the submission path forbids.
|
|
//
|
|
// The package owns no protocol or storage detail: peers are resolved and flagged
|
|
// through narrow ports the process wires to the existing app services, and every
|
|
// durable mutation goes through store.VerificationStore under the application's
|
|
// optimistic-locking version.
|
|
package verification
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"telesrv/internal/domain"
|
|
"telesrv/internal/store"
|
|
)
|
|
|
|
const (
|
|
// defaultListLimit / maxListLimit bound one review-queue page.
|
|
defaultListLimit = 50
|
|
maxListLimit = 200
|
|
// defaultEventLimit / maxEventLimit bound one application history page.
|
|
defaultEventLimit = 50
|
|
maxEventLimit = 200
|
|
// defaultApplicantLimit / maxApplicantLimit bound the applicant's own
|
|
// history as rendered by the bot's /status command.
|
|
defaultApplicantLimit = 20
|
|
maxApplicantLimit = 100
|
|
// defaultNotifyBatch / maxNotifyBatch bound one notification cycle.
|
|
defaultNotifyBatch = 50
|
|
maxNotifyBatch = 500
|
|
|
|
// defaultRejectCooldown matches the shipped
|
|
// TELESRV_VERIFICATION_REJECT_COOLDOWN default: a rejected applicant waits a
|
|
// month before filing the same target again.
|
|
defaultRejectCooldown = 720 * time.Hour
|
|
// defaultApplyRateLimit / defaultApplyRateWindow match the shipped
|
|
// TELESRV_VERIFICATION_APPLY_RATE_LIMIT / _WINDOW defaults.
|
|
defaultApplyRateLimit = 3
|
|
defaultApplyRateWindow = 24 * time.Hour
|
|
// defaultMaxActivePerUser matches TELESRV_VERIFICATION_MAX_ACTIVE_PER_USER.
|
|
defaultMaxActivePerUser = 3
|
|
|
|
// applyRateLimitKeyPrefix namespaces the per-applicant creation budget in the
|
|
// shared window limiter.
|
|
applyRateLimitKeyPrefix = "verification:apply:"
|
|
|
|
// maxActiveScanLimit bounds the applicant-history page the active-application
|
|
// cap is counted from. The cap itself is validated to be far below it.
|
|
maxActiveScanLimit = 100
|
|
// maxEligibleTargets bounds one target-picker answer, so an operator account
|
|
// administering thousands of channels cannot turn the picker into a scan.
|
|
maxEligibleTargets = 100
|
|
)
|
|
|
|
// Notification kinds written to the outbox by the store and handed to the
|
|
// ApplicantNotifier verbatim. They are declared here because the bot renders one
|
|
// message per kind and the two must agree.
|
|
const (
|
|
NoticeKindSubmitted = "submitted"
|
|
NoticeKindApproved = "approved"
|
|
NoticeKindRejected = "rejected"
|
|
NoticeKindCancelled = "cancelled"
|
|
NoticeKindRevoked = "revoked"
|
|
)
|
|
|
|
// ErrDisabled reports that official verification is switched off for this
|
|
// deployment. Every entry point refuses explicitly rather than answering with an
|
|
// empty result: a bot dialog and an admin panel must be able to tell "nothing to
|
|
// show" from "this deployment does not run verification". The notification cycle
|
|
// is the single exception, because a worker cadence is not a user action.
|
|
var ErrDisabled = errors.New("official verification is disabled")
|
|
|
|
// UserDirectory resolves viewer-independent account facts. users.Service
|
|
// satisfies it directly: AdminUser is the existing "administrative truth about
|
|
// an account" reader, which is exactly what an eligibility check needs (no
|
|
// viewer projection, no privacy filtering).
|
|
type UserDirectory interface {
|
|
AdminUser(ctx context.Context, userID int64) (domain.User, bool, error)
|
|
}
|
|
|
|
// BotDirectory resolves the applicant's bots and their ownership.
|
|
// bots.Service satisfies it directly.
|
|
type BotDirectory interface {
|
|
ListOwnedBots(ctx context.Context, ownerUserID int64) ([]domain.User, error)
|
|
OwnsBot(ctx context.Context, ownerUserID, botUserID int64) (bool, error)
|
|
}
|
|
|
|
// ChannelDirectory resolves channel facts and the applicant's administered
|
|
// public channels/supergroups. channels.Service satisfies it directly.
|
|
//
|
|
// ListAdminedPublicChannels is deliberately reused as the authority on control:
|
|
// it already returns only active creator/administrator memberships of public,
|
|
// non-deleted channels, so this package never re-derives admin rights and cannot
|
|
// drift from the channel aggregate's own answer.
|
|
type ChannelDirectory interface {
|
|
GetChannelByID(ctx context.Context, channelID int64) (domain.Channel, error)
|
|
ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error)
|
|
}
|
|
|
|
// PeerDirectory is the whole peer-resolution surface this service needs. It is
|
|
// the union of the three narrow ports so a process that has one aggregate facade
|
|
// can inject it once; the individual options exist because the shipped process
|
|
// has three separate services.
|
|
type PeerDirectory interface {
|
|
UserDirectory
|
|
BotDirectory
|
|
ChannelDirectory
|
|
}
|
|
|
|
// AccountFreezeProvider reports the durable account-level read-only state. It is
|
|
// the same port shape help/users/dialogs already use, so admin.Service satisfies
|
|
// it directly. Optional: without it a frozen account is not detectable here and
|
|
// the restriction check falls back to the deleted/scam/fake flags.
|
|
type AccountFreezeProvider interface {
|
|
AccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error)
|
|
}
|
|
|
|
// PeerVerifier flips the platform verification flag on a peer record. It is
|
|
// invoked from inside the store's decision transaction, so "approved" and
|
|
// "target carries the badge" commit together or not at all.
|
|
type PeerVerifier interface {
|
|
SetUserVerified(ctx context.Context, userID int64, verified bool) error
|
|
SetChannelVerified(ctx context.Context, channelID int64, verified bool) error
|
|
}
|
|
|
|
// PeerNotifier is the protocol edge hook invoked after a decision has already
|
|
// committed: it invalidates the cached peer projections and pushes the change to
|
|
// online clients. rpc.Router implements it. A push failure never invalidates the
|
|
// decision, so it is logged rather than returned.
|
|
type PeerNotifier interface {
|
|
NotifyPeerVerified(ctx context.Context, peer domain.Peer) error
|
|
}
|
|
|
|
// ApplicantNotifier delivers one queued outbox row as a @verifybot message. The
|
|
// bots service implements it; kind is the outbox row's kind (see NoticeKind*).
|
|
type ApplicantNotifier interface {
|
|
SendVerificationNotice(ctx context.Context, recipientUserID int64, app domain.VerificationApplication, kind string) error
|
|
}
|
|
|
|
// RateLimiter is the windowed limiter used to bound application creation. It is
|
|
// an alias of store.RateLimiter rather than a copy, so the process-wide limiter
|
|
// satisfies it with no adapter and the two can never drift apart.
|
|
type RateLimiter = store.RateLimiter
|
|
|
|
// Service is the official verification use-case layer.
|
|
type Service struct {
|
|
store store.VerificationStore
|
|
users UserDirectory
|
|
bots BotDirectory
|
|
channels ChannelDirectory
|
|
freezes AccountFreezeProvider
|
|
|
|
verifier PeerVerifier
|
|
peers PeerNotifier
|
|
applicant ApplicantNotifier
|
|
|
|
limiter RateLimiter
|
|
applyLimit int
|
|
applyWindow time.Duration
|
|
|
|
enabled bool
|
|
allowUserTargets bool
|
|
rejectCooldown time.Duration
|
|
maxActivePerUser int
|
|
|
|
now func() time.Time
|
|
log *zap.Logger
|
|
}
|
|
|
|
// Option adjusts optional service dependencies.
|
|
type Option func(*Service)
|
|
|
|
// WithStore injects the verification application store.
|
|
func WithStore(st store.VerificationStore) Option {
|
|
return func(s *Service) { s.store = st }
|
|
}
|
|
|
|
// WithPeerDirectory injects one facade covering user, bot and channel
|
|
// resolution.
|
|
func WithPeerDirectory(dir PeerDirectory) Option {
|
|
return func(s *Service) {
|
|
if dir == nil {
|
|
return
|
|
}
|
|
s.users = dir
|
|
s.bots = dir
|
|
s.channels = dir
|
|
}
|
|
}
|
|
|
|
// WithUserDirectory injects the account reader (users.Service).
|
|
func WithUserDirectory(dir UserDirectory) Option {
|
|
return func(s *Service) {
|
|
if dir != nil {
|
|
s.users = dir
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithBotDirectory injects the bot ownership reader (bots.Service).
|
|
func WithBotDirectory(dir BotDirectory) Option {
|
|
return func(s *Service) {
|
|
if dir != nil {
|
|
s.bots = dir
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithChannelDirectory injects the channel reader (channels.Service).
|
|
func WithChannelDirectory(dir ChannelDirectory) Option {
|
|
return func(s *Service) {
|
|
if dir != nil {
|
|
s.channels = dir
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithAccountFreezeProvider injects the account freeze reader (admin.Service).
|
|
func WithAccountFreezeProvider(provider AccountFreezeProvider) Option {
|
|
return func(s *Service) {
|
|
if provider != nil {
|
|
s.freezes = provider
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithPeerVerifier injects the flag writer.
|
|
func WithPeerVerifier(verifier PeerVerifier) Option {
|
|
return func(s *Service) {
|
|
if verifier != nil {
|
|
s.verifier = verifier
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithPeerNotifier injects the projection-invalidation/push hook.
|
|
func WithPeerNotifier(notifier PeerNotifier) Option {
|
|
return func(s *Service) {
|
|
if notifier != nil {
|
|
s.peers = notifier
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithApplicantNotifier injects the @verifybot delivery port.
|
|
func WithApplicantNotifier(notifier ApplicantNotifier) Option {
|
|
return func(s *Service) {
|
|
if notifier != nil {
|
|
s.applicant = notifier
|
|
}
|
|
}
|
|
}
|
|
|
|
// SetPeerNotifier installs the protocol-edge hook after construction. The
|
|
// router is built after the services it serves, so the push hook cannot be an
|
|
// option in every process; this mirrors usernames.Service.SetPeerUsernameNotifier.
|
|
func (s *Service) SetPeerNotifier(notifier PeerNotifier) {
|
|
if s == nil || notifier == nil {
|
|
return
|
|
}
|
|
s.peers = notifier
|
|
}
|
|
|
|
// SetApplicantNotifier installs the @verifybot delivery port after
|
|
// construction, for processes where the bot service is wired to this service in
|
|
// turn and cannot be passed as an option.
|
|
func (s *Service) SetApplicantNotifier(notifier ApplicantNotifier) {
|
|
if s == nil || notifier == nil {
|
|
return
|
|
}
|
|
s.applicant = notifier
|
|
}
|
|
|
|
// WithRateLimiter installs the application-creation budget. A non-positive limit
|
|
// or window disables the check, matching how the RPC edge treats its own limits.
|
|
func WithRateLimiter(limiter RateLimiter, limit int, window time.Duration) Option {
|
|
return func(s *Service) {
|
|
s.limiter = limiter
|
|
s.applyLimit = limit
|
|
s.applyWindow = window
|
|
}
|
|
}
|
|
|
|
// WithEnabled toggles the feature.
|
|
func WithEnabled(enabled bool) Option {
|
|
return func(s *Service) { s.enabled = enabled }
|
|
}
|
|
|
|
// WithAllowUserTargets opts plain user accounts in as verification subjects.
|
|
// Off by default: the official process verifies a public presence, and a private
|
|
// account has nothing to check.
|
|
func WithAllowUserTargets(allow bool) Option {
|
|
return func(s *Service) { s.allowUserTargets = allow }
|
|
}
|
|
|
|
// WithRejectCooldown configures how long a rejected applicant/target pair must
|
|
// wait before re-filing. Zero disables the cooldown.
|
|
func WithRejectCooldown(cooldown time.Duration) Option {
|
|
return func(s *Service) {
|
|
if cooldown >= 0 {
|
|
s.rejectCooldown = cooldown
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithMaxActivePerUser bounds how many applications one applicant may keep open.
|
|
// Zero disables the cap.
|
|
func WithMaxActivePerUser(limit int) Option {
|
|
return func(s *Service) {
|
|
if limit >= 0 {
|
|
s.maxActivePerUser = limit
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithClock injects the clock (tests).
|
|
func WithClock(now func() time.Time) Option {
|
|
return func(s *Service) {
|
|
if now != nil {
|
|
s.now = now
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithLogger injects the service logger.
|
|
func WithLogger(log *zap.Logger) Option {
|
|
return func(s *Service) {
|
|
if log != nil {
|
|
s.log = log
|
|
}
|
|
}
|
|
}
|
|
|
|
// NewService creates the verification service. It is enabled by default so the
|
|
// only switch is the configuration flag, and it is safe without dependencies:
|
|
// every entry point reports a configuration error instead of proceeding with a
|
|
// half-wired security check.
|
|
func NewService(opts ...Option) *Service {
|
|
s := &Service{
|
|
applyLimit: defaultApplyRateLimit,
|
|
applyWindow: defaultApplyRateWindow,
|
|
enabled: true,
|
|
rejectCooldown: defaultRejectCooldown,
|
|
maxActivePerUser: defaultMaxActivePerUser,
|
|
now: time.Now,
|
|
log: zap.NewNop(),
|
|
}
|
|
for _, opt := range opts {
|
|
if opt != nil {
|
|
opt(s)
|
|
}
|
|
}
|
|
if s.now == nil {
|
|
s.now = time.Now
|
|
}
|
|
if s.log == nil {
|
|
s.log = zap.NewNop()
|
|
}
|
|
if s.rejectCooldown < 0 {
|
|
s.rejectCooldown = 0
|
|
}
|
|
if s.maxActivePerUser < 0 {
|
|
s.maxActivePerUser = 0
|
|
}
|
|
if s.applyLimit < 0 {
|
|
s.applyLimit = 0
|
|
}
|
|
if s.applyWindow < 0 {
|
|
s.applyWindow = 0
|
|
}
|
|
return s
|
|
}
|
|
|
|
// Enabled reports whether the feature is switched on.
|
|
func (s *Service) Enabled() bool { return s != nil && s.enabled }
|
|
|
|
// Ready reports whether the feature is on and backed by a store, which is what
|
|
// the notification worker gates on.
|
|
func (s *Service) Ready() bool { return s.Enabled() && s.store != nil }
|
|
|
|
// AllowsUserTargets reports whether plain user accounts may be filed, so the
|
|
// bot can shape its target picker without duplicating the policy.
|
|
func (s *Service) AllowsUserTargets() bool { return s != nil && s.allowUserTargets }
|
|
|
|
func (s *Service) verificationStore() (store.VerificationStore, error) {
|
|
if s == nil || !s.enabled {
|
|
return nil, ErrDisabled
|
|
}
|
|
if s.store == nil {
|
|
return nil, fmt.Errorf("verification store is not configured")
|
|
}
|
|
return s.store, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Applicant side
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// EligibleTargets lists the peers the applicant controls together with whether
|
|
// each one can be filed right now. Ineligible candidates are returned with the
|
|
// domain reason rather than dropped, so the bot can explain "this channel is
|
|
// already verified" instead of silently hiding it.
|
|
//
|
|
// Ownership is not re-derived here: the candidates come from the ownership
|
|
// scoped listings themselves (owned bots, administered public channels), and the
|
|
// applicant's own account is only offered when user targets are enabled.
|
|
func (s *Service) EligibleTargets(ctx context.Context, applicantUserID int64) ([]domain.VerificationTarget, error) {
|
|
st, err := s.verificationStore()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if applicantUserID <= 0 {
|
|
return nil, domain.ErrVerificationApplicationInvalid
|
|
}
|
|
states, err := s.candidateStates(ctx, applicantUserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// The per-applicant open-application cap is a property of the applicant, not
|
|
// of any one candidate: when it is reached every candidate is reported
|
|
// ineligible with the same reason instead of failing later at StartDraft.
|
|
capReached := false
|
|
if err := s.checkActiveCap(ctx, st, applicantUserID); err != nil {
|
|
if !errors.Is(err, domain.ErrVerificationRateLimited) {
|
|
return nil, err
|
|
}
|
|
capReached = true
|
|
}
|
|
out := make([]domain.VerificationTarget, 0, len(states))
|
|
for _, state := range states {
|
|
target := state.target
|
|
target.Eligible = true
|
|
switch {
|
|
case capReached:
|
|
target.Eligible = false
|
|
target.Reason = domain.ErrVerificationRateLimited.Error()
|
|
default:
|
|
if err := s.evaluateState(ctx, applicantUserID, state, true); err != nil {
|
|
if !isVerificationPolicyError(err) {
|
|
return nil, err
|
|
}
|
|
target.Eligible = false
|
|
target.Reason = err.Error()
|
|
} else if err := s.checkTargetSlot(ctx, st, applicantUserID, state.target.Type, state.target.ID); err != nil {
|
|
if !isVerificationPolicyError(err) {
|
|
return nil, err
|
|
}
|
|
target.Eligible = false
|
|
target.Reason = err.Error()
|
|
}
|
|
}
|
|
out = append(out, target)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// StartDraft opens (or resumes) the applicant's draft for a target.
|
|
//
|
|
// Only the draft-level payload bar is enforced here; the full official bar
|
|
// (category, description length, website, independent press links) is enforced
|
|
// by Submit, because the bot fills the form one step at a time. Every target
|
|
// state check runs before anything is written, and the stored target snapshot is
|
|
// taken from the resolved peer rather than from the request, so a client cannot
|
|
// write an arbitrary title or username into the audit record.
|
|
func (s *Service) StartDraft(ctx context.Context, req domain.SubmitVerificationApplicationRequest) (domain.VerificationApplication, bool, error) {
|
|
st, err := s.verificationStore()
|
|
if err != nil {
|
|
return domain.VerificationApplication{}, false, err
|
|
}
|
|
if req.ApplicantUserID <= 0 || req.TargetID <= 0 || !req.TargetType.Valid() {
|
|
return domain.VerificationApplication{}, false, domain.ErrVerificationApplicationInvalid
|
|
}
|
|
if utf8.RuneCountInString(req.TargetTitle) > domain.MaxVerificationTitleLength ||
|
|
len(req.CorrelationID) > domain.MaxVerificationCorrelationLen {
|
|
return domain.VerificationApplication{}, false, domain.ErrVerificationApplicationInvalid
|
|
}
|
|
req.Draft = req.Draft.Normalize()
|
|
// The links are validated by the domain (ValidateVerificationURL), which
|
|
// refuses non-http(s) schemes, credentials and every non-public address range.
|
|
// The server NEVER fetches a submitted link -- not at submission, not during
|
|
// review, not from the admin panel. That is a deliberate anti-SSRF decision:
|
|
// an applicant-controlled URL must never become an outbound request from
|
|
// inside the deployment's network, so validation is the only thing done to it.
|
|
if err := req.Draft.ValidateDraft(); err != nil {
|
|
return domain.VerificationApplication{}, false, err
|
|
}
|
|
// One open draft per applicant, which is also the store's contract: an
|
|
// existing draft is resumed rather than replaced, and resuming spends no
|
|
// creation budget.
|
|
if existing, found, err := s.applicantDraft(ctx, st, req.ApplicantUserID); err != nil {
|
|
return domain.VerificationApplication{}, false, err
|
|
} else if found {
|
|
return existing, false, nil
|
|
}
|
|
target, err := s.checkTarget(ctx, req.ApplicantUserID, req.TargetType, req.TargetID)
|
|
if err != nil {
|
|
return domain.VerificationApplication{}, false, err
|
|
}
|
|
// The resolved kind wins over the requested one, so a supergroup filed as a
|
|
// channel is stored (and rate-limited, and cooldown-tracked) under the kind it
|
|
// actually is.
|
|
req.TargetType = target.Type
|
|
if err := s.checkTargetSlot(ctx, st, req.ApplicantUserID, req.TargetType, req.TargetID); err != nil {
|
|
return domain.VerificationApplication{}, false, err
|
|
}
|
|
if err := s.checkActiveCap(ctx, st, req.ApplicantUserID); err != nil {
|
|
return domain.VerificationApplication{}, false, err
|
|
}
|
|
if err := s.checkApplyRate(ctx, req.ApplicantUserID); err != nil {
|
|
return domain.VerificationApplication{}, false, err
|
|
}
|
|
req.TargetTitle = target.Title
|
|
req.TargetUsername = target.Username
|
|
return st.CreateVerificationDraft(ctx, req)
|
|
}
|
|
|
|
// SaveDraft rewrites the applicant-supplied payload of an own draft.
|
|
func (s *Service) SaveDraft(ctx context.Context, applicantUserID, applicationID, version int64, draft domain.VerificationDraftInput) (domain.VerificationApplication, error) {
|
|
st, err := s.verificationStore()
|
|
if err != nil {
|
|
return domain.VerificationApplication{}, err
|
|
}
|
|
draft = draft.Normalize()
|
|
// Domain link validation only; the server never dereferences these URLs.
|
|
if err := draft.ValidateDraft(); err != nil {
|
|
return domain.VerificationApplication{}, err
|
|
}
|
|
app, err := s.ownedApplication(ctx, st, applicantUserID, applicationID, version)
|
|
if err != nil {
|
|
return domain.VerificationApplication{}, err
|
|
}
|
|
if !app.Editable() {
|
|
return domain.VerificationApplication{}, domain.ErrVerificationStatusInvalid
|
|
}
|
|
return st.SaveVerificationDraft(ctx, applicationID, version, draft)
|
|
}
|
|
|
|
// Submit moves the applicant's draft into the review queue.
|
|
//
|
|
// The complete official bar is checked here, and so is the target state: a draft
|
|
// may have been sitting in the bot dialog for days, and the peer it names can
|
|
// have changed hands, lost its username or been flagged in the meantime.
|
|
func (s *Service) Submit(ctx context.Context, applicantUserID, applicationID, version int64) (domain.VerificationApplication, error) {
|
|
st, err := s.verificationStore()
|
|
if err != nil {
|
|
return domain.VerificationApplication{}, err
|
|
}
|
|
app, err := s.ownedApplication(ctx, st, applicantUserID, applicationID, version)
|
|
if err != nil {
|
|
return domain.VerificationApplication{}, err
|
|
}
|
|
if !domain.CanTransitionVerificationStatus(app.Status, domain.VerificationStatusSubmitted) {
|
|
return domain.VerificationApplication{}, domain.ErrVerificationStatusInvalid
|
|
}
|
|
if err := draftOf(app).ValidateForSubmission(); err != nil {
|
|
return domain.VerificationApplication{}, err
|
|
}
|
|
if _, err := s.checkTarget(ctx, app.ApplicantUserID, app.TargetType, app.TargetID); err != nil {
|
|
return domain.VerificationApplication{}, err
|
|
}
|
|
// The draft already occupies the target's single active slot, so only the
|
|
// re-application cooldown is re-checked here.
|
|
if err := s.checkCooldown(ctx, st, app.ApplicantUserID, app.TargetType, app.TargetID); err != nil {
|
|
return domain.VerificationApplication{}, err
|
|
}
|
|
return st.SubmitVerificationApplication(ctx, applicationID, version)
|
|
}
|
|
|
|
// Cancel withdraws an active application on the applicant's behalf.
|
|
func (s *Service) Cancel(ctx context.Context, applicantUserID, applicationID, version int64, reason string) (domain.VerificationApplication, error) {
|
|
st, err := s.verificationStore()
|
|
if err != nil {
|
|
return domain.VerificationApplication{}, err
|
|
}
|
|
reason = strings.TrimSpace(reason)
|
|
if utf8.RuneCountInString(reason) > domain.MaxVerificationReasonLength {
|
|
return domain.VerificationApplication{}, domain.ErrVerificationApplicationInvalid
|
|
}
|
|
app, err := s.ownedApplication(ctx, st, applicantUserID, applicationID, version)
|
|
if err != nil {
|
|
return domain.VerificationApplication{}, err
|
|
}
|
|
if !domain.CanTransitionVerificationStatus(app.Status, domain.VerificationStatusCancelled) {
|
|
return domain.VerificationApplication{}, domain.ErrVerificationStatusInvalid
|
|
}
|
|
return st.CancelVerificationApplication(ctx, applicationID, version, reason)
|
|
}
|
|
|
|
// Draft returns the applicant's open draft, if any.
|
|
func (s *Service) Draft(ctx context.Context, applicantUserID int64) (domain.VerificationApplication, error) {
|
|
st, err := s.verificationStore()
|
|
if err != nil {
|
|
return domain.VerificationApplication{}, err
|
|
}
|
|
if applicantUserID <= 0 {
|
|
return domain.VerificationApplication{}, domain.ErrVerificationApplicationInvalid
|
|
}
|
|
return st.VerificationDraftForApplicant(ctx, applicantUserID)
|
|
}
|
|
|
|
// Application reads one application by id. This is the reviewer-side read: the
|
|
// applicant paths go through ownedApplication, which additionally scopes by
|
|
// applicant.
|
|
func (s *Service) Application(ctx context.Context, applicationID int64) (domain.VerificationApplication, error) {
|
|
st, err := s.verificationStore()
|
|
if err != nil {
|
|
return domain.VerificationApplication{}, err
|
|
}
|
|
if applicationID <= 0 {
|
|
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
|
|
}
|
|
return st.VerificationApplication(ctx, applicationID)
|
|
}
|
|
|
|
// ApplicantApplications returns the applicant's own history, newest first.
|
|
func (s *Service) ApplicantApplications(ctx context.Context, applicantUserID int64, limit int) ([]domain.VerificationApplication, error) {
|
|
st, err := s.verificationStore()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if applicantUserID <= 0 {
|
|
return nil, domain.ErrVerificationApplicationInvalid
|
|
}
|
|
return st.VerificationApplicationsForApplicant(ctx, applicantUserID, clampLimit(limit, defaultApplicantLimit, maxApplicantLimit))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Reviewer side
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// List is the review-queue query with a bounded page size.
|
|
func (s *Service) List(ctx context.Context, filter domain.VerificationApplicationFilter) ([]domain.VerificationApplication, error) {
|
|
st, err := s.verificationStore()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
filter.Limit = clampLimit(filter.Limit, defaultListLimit, maxListLimit)
|
|
if filter.TargetType != "" && !filter.TargetType.Valid() {
|
|
return nil, domain.ErrVerificationTargetInvalid
|
|
}
|
|
for _, status := range filter.Statuses {
|
|
if !status.Valid() {
|
|
return nil, domain.ErrVerificationStatusInvalid
|
|
}
|
|
}
|
|
return st.ListVerificationApplications(ctx, filter)
|
|
}
|
|
|
|
// Counts is the queue summary rendered above the list.
|
|
func (s *Service) Counts(ctx context.Context) (domain.VerificationStatusCounts, error) {
|
|
st, err := s.verificationStore()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return st.VerificationStatusCounts(ctx)
|
|
}
|
|
|
|
// Events returns one application's immutable history, newest first.
|
|
func (s *Service) Events(ctx context.Context, applicationID int64, limit int) ([]domain.VerificationApplicationEvent, error) {
|
|
st, err := s.verificationStore()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if applicationID <= 0 {
|
|
return nil, domain.ErrVerificationApplicationNotFound
|
|
}
|
|
return st.VerificationApplicationEvents(ctx, applicationID, clampLimit(limit, defaultEventLimit, maxEventLimit))
|
|
}
|
|
|
|
// Claim assigns a reviewer to a submitted application. The store enforces the
|
|
// version and the status transition atomically, so two reviewers opening the
|
|
// same row produce exactly one owner.
|
|
func (s *Service) Claim(ctx context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, error) {
|
|
st, err := s.verificationStore()
|
|
if err != nil {
|
|
return domain.VerificationApplication{}, err
|
|
}
|
|
if err := decision.Validate(); err != nil {
|
|
return domain.VerificationApplication{}, err
|
|
}
|
|
return st.ClaimVerificationApplication(ctx, decision)
|
|
}
|
|
|
|
// Approve grants the platform badge.
|
|
//
|
|
// The target snapshot is reloaded and every eligibility check is re-run before
|
|
// the decision is recorded: approval is the only path that flips the flag, so it
|
|
// is also the last place the invariants can still be enforced. The flag itself
|
|
// is set from inside the store transaction through PeerVerifier, so "approved"
|
|
// and "verified" commit together. The post-commit push is best effort: the data
|
|
// is already consistent, and a failed push must not turn a landed decision into
|
|
// an error the panel would retry.
|
|
func (s *Service) Approve(ctx context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, bool, error) {
|
|
st, err := s.verificationStore()
|
|
if err != nil {
|
|
return domain.VerificationApplication{}, false, err
|
|
}
|
|
if err := decision.Validate(); err != nil {
|
|
return domain.VerificationApplication{}, false, err
|
|
}
|
|
if s.verifier == nil {
|
|
return domain.VerificationApplication{}, false, fmt.Errorf("verification peer verifier is not configured")
|
|
}
|
|
app, err := st.VerificationApplication(ctx, decision.ApplicationID)
|
|
if err != nil {
|
|
return domain.VerificationApplication{}, false, err
|
|
}
|
|
// A retried approval of an application that is already approved is an
|
|
// idempotent no-op: the decision, the flag and the single outbox row are
|
|
// already durable, so nothing is written and nothing is notified again.
|
|
if app.Status == domain.VerificationStatusApproved {
|
|
return app, false, nil
|
|
}
|
|
if !domain.CanTransitionVerificationStatus(app.Status, domain.VerificationStatusApproved) {
|
|
return domain.VerificationApplication{}, false, domain.ErrVerificationStatusInvalid
|
|
}
|
|
if app.Version != decision.Version {
|
|
return domain.VerificationApplication{}, false, domain.ErrVerificationVersionConflict
|
|
}
|
|
if _, err := s.checkTarget(ctx, app.ApplicantUserID, app.TargetType, app.TargetID); err != nil {
|
|
return domain.VerificationApplication{}, false, err
|
|
}
|
|
stored, changed, err := st.DecideVerificationApplication(ctx, decision, true, func(ctx context.Context, decided domain.VerificationApplication) error {
|
|
return s.applyVerified(ctx, decided.TargetType, decided.TargetID, true)
|
|
})
|
|
if err != nil {
|
|
return domain.VerificationApplication{}, false, err
|
|
}
|
|
if changed {
|
|
s.notifyPeer(ctx, stored.Target(), "approved")
|
|
}
|
|
return stored, changed, nil
|
|
}
|
|
|
|
// Reject closes an application against the applicant. A reason is mandatory:
|
|
// the audit trail must never contain a decision nobody can explain.
|
|
func (s *Service) Reject(ctx context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, bool, error) {
|
|
st, err := s.verificationStore()
|
|
if err != nil {
|
|
return domain.VerificationApplication{}, false, err
|
|
}
|
|
if err := decision.ValidateWithReason(); err != nil {
|
|
return domain.VerificationApplication{}, false, err
|
|
}
|
|
app, err := st.VerificationApplication(ctx, decision.ApplicationID)
|
|
if err != nil {
|
|
return domain.VerificationApplication{}, false, err
|
|
}
|
|
if app.Status == domain.VerificationStatusRejected {
|
|
return app, false, nil
|
|
}
|
|
if !domain.CanTransitionVerificationStatus(app.Status, domain.VerificationStatusRejected) {
|
|
return domain.VerificationApplication{}, false, domain.ErrVerificationStatusInvalid
|
|
}
|
|
if app.Version != decision.Version {
|
|
return domain.VerificationApplication{}, false, domain.ErrVerificationVersionConflict
|
|
}
|
|
// A rejection touches no peer record, so no applyVerified callback is passed.
|
|
return st.DecideVerificationApplication(ctx, decision, false, nil)
|
|
}
|
|
|
|
// Revoke clears the platform badge of a previously approved target. The
|
|
// application stays approved: it is history, and the revocation is its own audit
|
|
// event. The flag is cleared through the same in-transaction callback discipline
|
|
// as the approval that set it.
|
|
func (s *Service) Revoke(ctx context.Context, req domain.VerificationRevocation) (domain.VerificationApplication, bool, error) {
|
|
st, err := s.verificationStore()
|
|
if err != nil {
|
|
return domain.VerificationApplication{}, false, err
|
|
}
|
|
if err := req.Validate(); err != nil {
|
|
return domain.VerificationApplication{}, false, err
|
|
}
|
|
if s.verifier == nil {
|
|
return domain.VerificationApplication{}, false, fmt.Errorf("verification peer verifier is not configured")
|
|
}
|
|
// A built-in system account carries its badge by construction; revoking it
|
|
// would desynchronise the seeded record from domain.SystemUserByID.
|
|
if req.TargetType.PeerType() == domain.PeerTypeUser && domain.IsSystemUserID(req.TargetID) {
|
|
return domain.VerificationApplication{}, false, domain.ErrVerificationTargetSystem
|
|
}
|
|
stored, changed, err := st.RevokeVerification(ctx, req, func(ctx context.Context, target domain.Peer) error {
|
|
return s.applyVerifiedPeer(ctx, target, false)
|
|
})
|
|
if err != nil {
|
|
return domain.VerificationApplication{}, false, err
|
|
}
|
|
if changed {
|
|
s.notifyPeer(ctx, domain.Peer{Type: req.TargetType.PeerType(), ID: req.TargetID}, "revoked")
|
|
}
|
|
return stored, changed, nil
|
|
}
|
|
|
|
// TargetSnapshot returns the current state of a target for the review UI: the
|
|
// title and username as they are now, the badge state, and whether the peer
|
|
// would pass the checks today. Ownership is not part of the answer because the
|
|
// snapshot is reviewer-facing and carries no applicant.
|
|
func (s *Service) TargetSnapshot(ctx context.Context, targetType domain.VerificationTargetType, targetID int64) (domain.VerificationTarget, error) {
|
|
if s == nil || !s.enabled {
|
|
return domain.VerificationTarget{}, ErrDisabled
|
|
}
|
|
if targetID <= 0 || !targetType.Valid() {
|
|
return domain.VerificationTarget{}, domain.ErrVerificationTargetInvalid
|
|
}
|
|
state, err := s.targetState(ctx, targetType, targetID)
|
|
if err != nil {
|
|
return domain.VerificationTarget{}, err
|
|
}
|
|
target := state.target
|
|
target.Eligible = true
|
|
// applicantUserID 0 skips the ownership probe; every other check still runs.
|
|
if err := s.evaluateState(ctx, 0, state, false); err != nil {
|
|
if !isVerificationPolicyError(err) {
|
|
return domain.VerificationTarget{}, err
|
|
}
|
|
target.Eligible = false
|
|
target.Reason = err.Error()
|
|
}
|
|
return target, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Notification delivery
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// RunNotificationCycle delivers one bounded batch of queued applicant
|
|
// notifications and returns how many were delivered.
|
|
//
|
|
// A single row's delivery failure is recorded on that row and the cycle
|
|
// continues: one applicant who blocked @verifybot must not stall the queue. The
|
|
// outbox row is the retry state, so a failed row is retried on the next cycle
|
|
// with its attempt counter advanced.
|
|
func (s *Service) RunNotificationCycle(ctx context.Context, limit int) (int, error) {
|
|
if s == nil || !s.enabled || s.store == nil {
|
|
return 0, nil
|
|
}
|
|
if s.applicant == nil {
|
|
return 0, fmt.Errorf("verification applicant notifier is not configured")
|
|
}
|
|
pending, err := s.store.PendingVerificationNotifications(ctx, clampLimit(limit, defaultNotifyBatch, maxNotifyBatch))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
delivered := 0
|
|
for _, notification := range pending {
|
|
if err := ctx.Err(); err != nil {
|
|
return delivered, err
|
|
}
|
|
if notification.ID <= 0 {
|
|
continue
|
|
}
|
|
if notification.RecipientUserID <= 0 {
|
|
s.failNotification(ctx, notification, errors.New("verification notification has no recipient"))
|
|
continue
|
|
}
|
|
if err := s.applicant.SendVerificationNotice(ctx, notification.RecipientUserID, notification.Application, notification.Kind); err != nil {
|
|
s.failNotification(ctx, notification, err)
|
|
continue
|
|
}
|
|
if err := s.store.MarkVerificationNotificationDelivered(ctx, notification.ID); err != nil {
|
|
s.log.Warn("mark verification notification delivered failed",
|
|
zap.Int64("notification_id", notification.ID),
|
|
zap.Int64("application_id", notification.ApplicationID),
|
|
zap.Error(err))
|
|
continue
|
|
}
|
|
delivered++
|
|
}
|
|
return delivered, nil
|
|
}
|
|
|
|
func (s *Service) failNotification(ctx context.Context, notification store.VerificationNotification, cause error) {
|
|
s.log.Warn("verification notification delivery failed",
|
|
zap.Int64("notification_id", notification.ID),
|
|
zap.Int64("application_id", notification.ApplicationID),
|
|
zap.Int64("recipient_user_id", notification.RecipientUserID),
|
|
zap.String("kind", notification.Kind),
|
|
zap.Int("attempts", notification.Attempts),
|
|
zap.Error(cause))
|
|
reason := truncateRunes(cause.Error(), domain.MaxVerificationReasonLength)
|
|
if err := s.store.MarkVerificationNotificationFailed(ctx, notification.ID, reason); err != nil {
|
|
s.log.Warn("mark verification notification failed",
|
|
zap.Int64("notification_id", notification.ID),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Security checks
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// targetState is a freshly loaded peer snapshot plus the derived restriction
|
|
// verdict. It exists so the checks can run either against a peer this package
|
|
// loaded by id or against one that came out of an ownership-scoped listing,
|
|
// without two copies of the rules.
|
|
type targetState struct {
|
|
target domain.VerificationTarget
|
|
restricted bool
|
|
}
|
|
|
|
// checkTarget loads the target and runs every eligibility check against it.
|
|
// It is called on the submission path and again, unchanged, at approval time.
|
|
func (s *Service) checkTarget(ctx context.Context, applicantUserID int64, targetType domain.VerificationTargetType, targetID int64) (domain.VerificationTarget, error) {
|
|
if targetID <= 0 || !targetType.Valid() {
|
|
return domain.VerificationTarget{}, domain.ErrVerificationTargetInvalid
|
|
}
|
|
state, err := s.targetState(ctx, targetType, targetID)
|
|
if err != nil {
|
|
return domain.VerificationTarget{}, err
|
|
}
|
|
if err := s.evaluateState(ctx, applicantUserID, state, false); err != nil {
|
|
return domain.VerificationTarget{}, err
|
|
}
|
|
return state.target, nil
|
|
}
|
|
|
|
// evaluateState runs the peer-state checks in a fixed order.
|
|
//
|
|
// The system-entity check comes first because a built-in account is nobody's
|
|
// property: without it a request naming @verifybot would be refused as "not
|
|
// owned", which hides the real reason. ownershipVerified skips the ownership
|
|
// probe for candidates that came from an ownership-scoped listing.
|
|
func (s *Service) evaluateState(ctx context.Context, applicantUserID int64, state targetState, ownershipVerified bool) error {
|
|
target := state.target
|
|
if !target.Type.Valid() || target.ID <= 0 {
|
|
return domain.ErrVerificationTargetInvalid
|
|
}
|
|
if target.Type == domain.VerificationTargetUser && !s.allowUserTargets {
|
|
return domain.ErrVerificationUserTargetsDisabled
|
|
}
|
|
if target.Type.PeerType() == domain.PeerTypeUser && domain.IsSystemUserID(target.ID) {
|
|
return domain.ErrVerificationTargetSystem
|
|
}
|
|
if strings.TrimSpace(target.Username) == "" {
|
|
return domain.ErrVerificationTargetNotPublic
|
|
}
|
|
if applicantUserID > 0 && !ownershipVerified {
|
|
owned, err := s.controls(ctx, applicantUserID, target.Type, target.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !owned {
|
|
return domain.ErrVerificationNotOwner
|
|
}
|
|
}
|
|
if target.Verified {
|
|
return domain.ErrVerificationTargetAlreadyVerified
|
|
}
|
|
if state.restricted {
|
|
return domain.ErrVerificationTargetRestricted
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// targetState resolves the current peer snapshot for a target.
|
|
func (s *Service) targetState(ctx context.Context, targetType domain.VerificationTargetType, targetID int64) (targetState, error) {
|
|
switch targetType {
|
|
case domain.VerificationTargetBot, domain.VerificationTargetUser:
|
|
if s.users == nil {
|
|
return targetState{}, fmt.Errorf("verification user directory is not configured")
|
|
}
|
|
user, found, err := s.users.AdminUser(ctx, targetID)
|
|
if err != nil {
|
|
return targetState{}, mapLookupError(err)
|
|
}
|
|
if !found || user.ID <= 0 {
|
|
return targetState{}, domain.ErrVerificationTargetInvalid
|
|
}
|
|
// A plain account filed under the bot target type would slip past
|
|
// TELESRV_VERIFICATION_ALLOW_USER_TARGETS, so the namespaces are pinned.
|
|
if targetType == domain.VerificationTargetBot && !user.Bot {
|
|
return targetState{}, domain.ErrVerificationTargetInvalid
|
|
}
|
|
frozen, err := s.frozen(ctx, user.ID)
|
|
if err != nil {
|
|
return targetState{}, err
|
|
}
|
|
return targetState{
|
|
target: domain.VerificationTarget{
|
|
Type: targetType,
|
|
ID: user.ID,
|
|
Title: userTitle(user),
|
|
Username: user.Username,
|
|
AccessHash: user.AccessHash,
|
|
Verified: user.Verified,
|
|
},
|
|
restricted: user.Deleted || user.Scam || user.Fake || frozen,
|
|
}, nil
|
|
case domain.VerificationTargetChannel, domain.VerificationTargetSupergroup:
|
|
if s.channels == nil {
|
|
return targetState{}, fmt.Errorf("verification channel directory is not configured")
|
|
}
|
|
channel, err := s.channels.GetChannelByID(ctx, targetID)
|
|
if err != nil {
|
|
return targetState{}, mapLookupError(err)
|
|
}
|
|
if channel.ID <= 0 {
|
|
return targetState{}, domain.ErrVerificationTargetInvalid
|
|
}
|
|
// The broadcast/megagroup distinction is cosmetic here: both live in the
|
|
// channel namespace and a group can be converted after submission, so the
|
|
// stored kind is reported back rather than used as a gate.
|
|
return targetState{
|
|
target: domain.VerificationTarget{
|
|
Type: channelTargetType(channel),
|
|
ID: channel.ID,
|
|
Title: channel.Title,
|
|
Username: channel.Username,
|
|
AccessHash: channel.AccessHash,
|
|
Verified: channel.Verified,
|
|
},
|
|
restricted: channel.Deleted || channel.Scam || channel.Fake,
|
|
}, nil
|
|
default:
|
|
return targetState{}, domain.ErrVerificationTargetInvalid
|
|
}
|
|
}
|
|
|
|
// controls reports whether the applicant controls the target.
|
|
//
|
|
// Bots are answered by the bot aggregate's own ownership check, channels and
|
|
// supergroups by the channel aggregate's administered-public listing, and a user
|
|
// target only by being that user: each authority stays where it belongs.
|
|
func (s *Service) controls(ctx context.Context, applicantUserID int64, targetType domain.VerificationTargetType, targetID int64) (bool, error) {
|
|
switch targetType {
|
|
case domain.VerificationTargetBot:
|
|
if s.bots == nil {
|
|
return false, fmt.Errorf("verification bot directory is not configured")
|
|
}
|
|
owned, err := s.bots.OwnsBot(ctx, applicantUserID, targetID)
|
|
if err != nil {
|
|
if isNotFoundError(err) {
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
return owned, nil
|
|
case domain.VerificationTargetUser:
|
|
return applicantUserID == targetID, nil
|
|
case domain.VerificationTargetChannel, domain.VerificationTargetSupergroup:
|
|
if s.channels == nil {
|
|
return false, fmt.Errorf("verification channel directory is not configured")
|
|
}
|
|
channels, err := s.channels.ListAdminedPublicChannels(ctx, applicantUserID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
for _, channel := range channels {
|
|
if channel.ID == targetID {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
default:
|
|
return false, domain.ErrVerificationTargetInvalid
|
|
}
|
|
}
|
|
|
|
// candidateStates collects the applicant's own peers for the target picker.
|
|
func (s *Service) candidateStates(ctx context.Context, applicantUserID int64) ([]targetState, error) {
|
|
out := make([]targetState, 0, 8)
|
|
if s.bots != nil {
|
|
bots, err := s.bots.ListOwnedBots(ctx, applicantUserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, bot := range bots {
|
|
if bot.ID <= 0 || domain.IsSystemUserID(bot.ID) {
|
|
continue
|
|
}
|
|
frozen, err := s.frozen(ctx, bot.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, targetState{
|
|
target: domain.VerificationTarget{
|
|
Type: domain.VerificationTargetBot,
|
|
ID: bot.ID,
|
|
Title: userTitle(bot),
|
|
Username: bot.Username,
|
|
AccessHash: bot.AccessHash,
|
|
Verified: bot.Verified,
|
|
},
|
|
restricted: bot.Deleted || bot.Scam || bot.Fake || frozen,
|
|
})
|
|
if len(out) >= maxEligibleTargets {
|
|
return out, nil
|
|
}
|
|
}
|
|
}
|
|
if s.channels != nil {
|
|
channels, err := s.channels.ListAdminedPublicChannels(ctx, applicantUserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, channel := range channels {
|
|
if channel.ID <= 0 {
|
|
continue
|
|
}
|
|
out = append(out, targetState{
|
|
target: domain.VerificationTarget{
|
|
Type: channelTargetType(channel),
|
|
ID: channel.ID,
|
|
Title: channel.Title,
|
|
Username: channel.Username,
|
|
AccessHash: channel.AccessHash,
|
|
Verified: channel.Verified,
|
|
},
|
|
restricted: channel.Deleted || channel.Scam || channel.Fake,
|
|
})
|
|
if len(out) >= maxEligibleTargets {
|
|
return out, nil
|
|
}
|
|
}
|
|
}
|
|
// The applicant's own account is only a candidate when the operator opted in.
|
|
if s.allowUserTargets && s.users != nil && len(out) < maxEligibleTargets {
|
|
state, err := s.targetState(ctx, domain.VerificationTargetUser, applicantUserID)
|
|
switch {
|
|
case err == nil:
|
|
out = append(out, state)
|
|
case isVerificationPolicyError(err):
|
|
default:
|
|
return nil, err
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// checkTargetSlot refuses a second live application on the same target and
|
|
// enforces the post-rejection cooldown.
|
|
func (s *Service) checkTargetSlot(ctx context.Context, st store.VerificationStore, applicantUserID int64, targetType domain.VerificationTargetType, targetID int64) error {
|
|
active, err := st.ActiveVerificationApplicationForTarget(ctx, targetType, targetID)
|
|
switch {
|
|
case err == nil:
|
|
if active.ID > 0 {
|
|
return domain.ErrVerificationApplicationExists
|
|
}
|
|
case errors.Is(err, domain.ErrVerificationApplicationNotFound):
|
|
default:
|
|
return err
|
|
}
|
|
return s.checkCooldown(ctx, st, applicantUserID, targetType, targetID)
|
|
}
|
|
|
|
// checkCooldown enforces the configured wait after a rejection of the same
|
|
// applicant/target pair. The wait is measured from the decision, not from the
|
|
// submission, so a slow review never shortens it.
|
|
func (s *Service) checkCooldown(ctx context.Context, st store.VerificationStore, applicantUserID int64, targetType domain.VerificationTargetType, targetID int64) error {
|
|
if s.rejectCooldown <= 0 {
|
|
return nil
|
|
}
|
|
last, err := st.LastVerificationRejection(ctx, applicantUserID, targetType, targetID)
|
|
switch {
|
|
case err == nil:
|
|
case errors.Is(err, domain.ErrVerificationApplicationNotFound):
|
|
return nil
|
|
default:
|
|
return err
|
|
}
|
|
if last.ID <= 0 {
|
|
return nil
|
|
}
|
|
decidedAt := last.ReviewedAt
|
|
if decidedAt.IsZero() {
|
|
decidedAt = last.UpdatedAt
|
|
}
|
|
if decidedAt.IsZero() {
|
|
return nil
|
|
}
|
|
if s.now().UTC().Sub(decidedAt.UTC()) < s.rejectCooldown {
|
|
return domain.ErrVerificationCooldown
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// checkActiveCap bounds how many applications one applicant may keep open.
|
|
func (s *Service) checkActiveCap(ctx context.Context, st store.VerificationStore, applicantUserID int64) error {
|
|
if s.maxActivePerUser <= 0 {
|
|
return nil
|
|
}
|
|
apps, err := st.VerificationApplicationsForApplicant(ctx, applicantUserID, maxActiveScanLimit)
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrVerificationApplicationNotFound) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
active := 0
|
|
for _, app := range apps {
|
|
if app.Status.Active() {
|
|
active++
|
|
}
|
|
}
|
|
if active >= s.maxActivePerUser {
|
|
return domain.ErrVerificationRateLimited
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// checkApplyRate spends one unit of the applicant's creation budget. It runs
|
|
// last among the creation checks, so a refused application never costs budget
|
|
// and an attacker cannot exhaust a victim's window by probing invalid targets.
|
|
func (s *Service) checkApplyRate(ctx context.Context, applicantUserID int64) error {
|
|
if s.limiter == nil || s.applyLimit <= 0 || s.applyWindow <= 0 {
|
|
return nil
|
|
}
|
|
allowed, retryAfter, err := s.limiter.Allow(ctx, applyRateLimitKeyPrefix+strconv.FormatInt(applicantUserID, 10), s.applyLimit, s.applyWindow)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if allowed {
|
|
return nil
|
|
}
|
|
s.log.Debug("verification application rate limited",
|
|
zap.Int64("applicant_user_id", applicantUserID),
|
|
zap.Int("retry_after", retryAfter))
|
|
return domain.ErrVerificationRateLimited
|
|
}
|
|
|
|
// frozen reports the account-level read-only state of a user-namespace target.
|
|
func (s *Service) frozen(ctx context.Context, userID int64) (bool, error) {
|
|
if s.freezes == nil || userID <= 0 {
|
|
return false, nil
|
|
}
|
|
freeze, found, err := s.freezes.AccountFreeze(ctx, userID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return found && freeze.Frozen, nil
|
|
}
|
|
|
|
// applyVerified writes the platform flag. It runs inside the store's decision
|
|
// transaction, so an error here rolls the decision back rather than leaving an
|
|
// approved application over an unflagged peer.
|
|
func (s *Service) applyVerified(ctx context.Context, targetType domain.VerificationTargetType, targetID int64, verified bool) error {
|
|
if !targetType.Valid() || targetID <= 0 {
|
|
return domain.ErrVerificationTargetInvalid
|
|
}
|
|
return s.applyVerifiedPeer(ctx, domain.Peer{Type: targetType.PeerType(), ID: targetID}, verified)
|
|
}
|
|
|
|
func (s *Service) applyVerifiedPeer(ctx context.Context, peer domain.Peer, verified bool) error {
|
|
if s.verifier == nil {
|
|
return fmt.Errorf("verification peer verifier is not configured")
|
|
}
|
|
if peer.ID <= 0 {
|
|
return domain.ErrVerificationTargetInvalid
|
|
}
|
|
switch peer.Type {
|
|
case domain.PeerTypeUser:
|
|
if domain.IsSystemUserID(peer.ID) {
|
|
return domain.ErrVerificationTargetSystem
|
|
}
|
|
return s.verifier.SetUserVerified(ctx, peer.ID, verified)
|
|
case domain.PeerTypeChannel:
|
|
return s.verifier.SetChannelVerified(ctx, peer.ID, verified)
|
|
default:
|
|
return domain.ErrVerificationTargetInvalid
|
|
}
|
|
}
|
|
|
|
// notifyPeer invalidates the cached peer projections and pushes the badge change
|
|
// to online clients. The decision has already committed, so a failure here is
|
|
// logged and swallowed: retrying the decision would be wrong, and the next
|
|
// authoritative peer read repairs the projection anyway.
|
|
func (s *Service) notifyPeer(ctx context.Context, peer domain.Peer, action string) {
|
|
if s.peers == nil || peer.ID <= 0 {
|
|
return
|
|
}
|
|
if err := s.peers.NotifyPeerVerified(ctx, peer); err != nil {
|
|
s.log.Warn("verification peer notification failed",
|
|
zap.String("action", action),
|
|
zap.String("peer_type", string(peer.Type)),
|
|
zap.Int64("peer_id", peer.ID),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ownedApplication loads an application and asserts that it belongs to the
|
|
// applicant and is at the expected version.
|
|
//
|
|
// A foreign application reports "not found" rather than "not owner": the
|
|
// applicant surface is a bot dialog, and it must not become an oracle for the
|
|
// existence of other people's applications.
|
|
func (s *Service) ownedApplication(ctx context.Context, st store.VerificationStore, applicantUserID, applicationID, version int64) (domain.VerificationApplication, error) {
|
|
if applicantUserID <= 0 || applicationID <= 0 {
|
|
return domain.VerificationApplication{}, domain.ErrVerificationApplicationInvalid
|
|
}
|
|
app, err := st.VerificationApplication(ctx, applicationID)
|
|
if err != nil {
|
|
return domain.VerificationApplication{}, err
|
|
}
|
|
if app.ID != applicationID || app.ApplicantUserID != applicantUserID {
|
|
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
|
|
}
|
|
if version <= 0 || app.Version != version {
|
|
return domain.VerificationApplication{}, domain.ErrVerificationVersionConflict
|
|
}
|
|
return app, nil
|
|
}
|
|
|
|
// applicantDraft returns the applicant's resumable draft, if any.
|
|
func (s *Service) applicantDraft(ctx context.Context, st store.VerificationStore, applicantUserID int64) (domain.VerificationApplication, bool, error) {
|
|
draft, err := st.VerificationDraftForApplicant(ctx, applicantUserID)
|
|
switch {
|
|
case err == nil:
|
|
case errors.Is(err, domain.ErrVerificationApplicationNotFound):
|
|
return domain.VerificationApplication{}, false, nil
|
|
default:
|
|
return domain.VerificationApplication{}, false, err
|
|
}
|
|
if draft.ID <= 0 || draft.Status != domain.VerificationStatusDraft {
|
|
return domain.VerificationApplication{}, false, nil
|
|
}
|
|
return draft, true, nil
|
|
}
|
|
|
|
// draftOf rebuilds the applicant payload from a stored application so the
|
|
// submission bar is checked against exactly what would be reviewed.
|
|
func draftOf(app domain.VerificationApplication) domain.VerificationDraftInput {
|
|
return domain.VerificationDraftInput{
|
|
Category: app.Category,
|
|
Description: app.Description,
|
|
OfficialWebsite: app.OfficialWebsite,
|
|
SocialLinks: app.SocialLinks,
|
|
PressLinks: app.PressLinks,
|
|
AdditionalNote: app.AdditionalNote,
|
|
}
|
|
}
|
|
|
|
func channelTargetType(channel domain.Channel) domain.VerificationTargetType {
|
|
if channel.Megagroup || channel.Gigagroup {
|
|
return domain.VerificationTargetSupergroup
|
|
}
|
|
return domain.VerificationTargetChannel
|
|
}
|
|
|
|
func userTitle(user domain.User) string {
|
|
title := strings.TrimSpace(strings.TrimSpace(user.FirstName) + " " + strings.TrimSpace(user.LastName))
|
|
return truncateRunes(title, domain.MaxVerificationTitleLength)
|
|
}
|
|
|
|
// isVerificationPolicyError reports whether the error is a domain verdict about
|
|
// the target rather than an infrastructure failure. The picker turns a verdict
|
|
// into an explanation and propagates everything else.
|
|
func isVerificationPolicyError(err error) bool {
|
|
switch {
|
|
case err == nil:
|
|
return false
|
|
case errors.Is(err, domain.ErrVerificationTargetInvalid),
|
|
errors.Is(err, domain.ErrVerificationTargetNotPublic),
|
|
errors.Is(err, domain.ErrVerificationTargetAlreadyVerified),
|
|
errors.Is(err, domain.ErrVerificationTargetRestricted),
|
|
errors.Is(err, domain.ErrVerificationTargetSystem),
|
|
errors.Is(err, domain.ErrVerificationNotOwner),
|
|
errors.Is(err, domain.ErrVerificationUserTargetsDisabled),
|
|
errors.Is(err, domain.ErrVerificationApplicationExists),
|
|
errors.Is(err, domain.ErrVerificationCooldown),
|
|
errors.Is(err, domain.ErrVerificationRateLimited):
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// mapLookupError folds a peer aggregate's "no such peer" answers into the
|
|
// verification vocabulary and leaves real failures alone.
|
|
func mapLookupError(err error) error {
|
|
if isNotFoundError(err) {
|
|
return domain.ErrVerificationTargetInvalid
|
|
}
|
|
return err
|
|
}
|
|
|
|
func isNotFoundError(err error) bool {
|
|
return errors.Is(err, domain.ErrUserNotFound) ||
|
|
errors.Is(err, domain.ErrBotNotFound) ||
|
|
errors.Is(err, domain.ErrChannelInvalid)
|
|
}
|
|
|
|
func clampLimit(limit, fallback, maximum int) int {
|
|
if limit <= 0 {
|
|
return fallback
|
|
}
|
|
if limit > maximum {
|
|
return maximum
|
|
}
|
|
return limit
|
|
}
|
|
|
|
func truncateRunes(value string, max int) string {
|
|
if max <= 0 || utf8.RuneCountInString(value) <= max {
|
|
return value
|
|
}
|
|
runes := []rune(value)
|
|
return string(runes[:max])
|
|
}
|