1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain
4
5import (
6 "crypto/rand"
7 "encoding/base32"
8 "fmt"
9 "strings"
10 "time"
11 "unicode/utf8"
12)
13
14// Limits of the public issue form (ADR 0031).
15const (
16 // MaxPendingIssues is the most issues awaiting moderation, over every
17 // repository. Past it submissions are refused.
18 MaxPendingIssues = 200
19 // SubmitPerHour and SubmitPerDay are the submissions one client may make
20 // in a sliding hour and a sliding day.
21 SubmitPerHour = 3
22 SubmitPerDay = 10
23 // BanDuration is how long a rejected submitter stays banned.
24 BanDuration = 30 * 24 * time.Hour
25 // MaxPendingAuthorLen is the longest display name of a submitter, in
26 // code points.
27 MaxPendingAuthorLen = 40
28 // PendingIssueIDLen is the length of a pending issue id: 16 random bytes
29 // in unpadded lowercase base32.
30 PendingIssueIDLen = 26
31 // IPHashLen is the size of the HMAC of a client key.
32 IPHashLen = 32
33)
34
35var pendingIDEncoding = base32.StdEncoding.WithPadding(base32.NoPadding)
36
37// PendingIssueID identifies an issue awaiting moderation. It is random and
38// opaque, not a number: a pending issue has none yet.
39type PendingIssueID string
40
41// NewPendingIssueID returns a fresh random id.
42func NewPendingIssueID() (PendingIssueID, error) {
43 var b [16]byte
44 if _, err := rand.Read(b[:]); err != nil {
45 return "", fmt.Errorf("generate pending issue id: %w", err)
46 }
47 return PendingIssueID(strings.ToLower(pendingIDEncoding.EncodeToString(b[:]))), nil
48}
49
50// ParsePendingIssueID accepts exactly what NewPendingIssueID produces: 26
51// characters of a-z and 2-7. Anything else gives an error wrapping ErrInvalid.
52func ParsePendingIssueID(s string) (PendingIssueID, error) {
53 if len(s) != PendingIssueIDLen {
54 return "", fmt.Errorf("%w: pending issue id", ErrInvalid)
55 }
56 for i := range len(s) {
57 c := s[i]
58 if (c < 'a' || c > 'z') && (c < '2' || c > '7') {
59 return "", fmt.Errorf("%w: pending issue id", ErrInvalid)
60 }
61 }
62 // 26 base32 characters carry 130 bits; the last one must not set the two
63 // spare bits, or the id would not round-trip.
64 raw, err := pendingIDEncoding.DecodeString(strings.ToUpper(s))
65 if err != nil || len(raw) != 16 || strings.ToLower(pendingIDEncoding.EncodeToString(raw)) != s {
66 return "", fmt.Errorf("%w: pending issue id", ErrInvalid)
67 }
68 return PendingIssueID(s), nil
69}
70
71// String returns the id as text.
72func (id PendingIssueID) String() string { return string(id) }
73
74// ParsePendingAuthor validates the optional display name of a submitter: valid
75// UTF-8, one line, at most MaxPendingAuthorLen code points, normalized.
76// The empty name is valid.
77func ParsePendingAuthor(s string) (string, error) {
78 if !utf8.ValidString(s) {
79 return "", &TextError{Reason: "is not valid UTF-8"}
80 }
81 c := cleanText(s, false)
82 if n := utf8.RuneCountInString(c); n > MaxPendingAuthorLen {
83 return "", &TextError{Reason: fmt.Sprintf("is longer than %d characters (%d)", MaxPendingAuthorLen, n)}
84 }
85 return c, nil
86}
87
88// AnonymousAuthor labels an approved issue whose submitter gave no name.
89const AnonymousAuthor = "anonymous"
90
91// ApprovedIssue is the open, external issue a pending submission becomes when
92// the owner approves it at now. The store sets the number. A submitter who gave
93// no name is AnonymousAuthor; the issue keeps the submission time as its
94// creation time.
95func ApprovedIssue(p PendingIssue, now time.Time) Issue {
96 author := p.Author
97 if author == "" {
98 author = AnonymousAuthor
99 }
100 return Issue{
101 Repo: p.Repo, Title: p.Title, Body: p.Body, State: IssueStateOpen,
102 Author: author, CreatedAt: p.CreatedAt, UpdatedAt: now, External: true,
103 }
104}
105
106// PendingIssue is an issue submitted through the public form, awaiting the
107// owner's decision. It has no number: approval gives it one.
108type PendingIssue struct {
109 ID PendingIssueID
110 Repo RepoName
111 Title IssueTitle
112 Body IssueBody
113 // Author is the display name the submitter gave; it may be empty and is
114 // not verified.
115 Author string
116 CreatedAt time.Time
117 // IPHash is the HMAC-SHA256 of the client key, IPHashLen bytes. It lets
118 // the owner ban a submitter without the server keeping an address.
119 IPHash []byte
120 // Version is set by the store on read; opaque to everyone else.
121 Version uint64
122}