1package web
2
3import (
4 "encoding/base64"
5 "math/rand/v2"
6 "slices"
7 "strings"
8 "sync"
9
10 "tug/internal/transport/web/qrsvg"
11)
12
13const (
14 BatchCapFloor = 5
15 batchCapSamples = 9
16 batchCapSeed = 0x7467
17 batchLabelBytes = 128
18 batchGroupNameBytes = 32
19 batchGroupsMore = 999999
20 batchLeftOut = 999
21 batchWorstExpires = "2006-01-02 15:04"
22)
23
24type batchCapEstimate struct {
25 Fixed int
26 Row int
27 Code int
28 Cap int
29}
30
31func (e batchCapEstimate) weight(n int) int {
32 return e.Fixed + n*(e.Row+e.Code)
33}
34
35func (e batchCapEstimate) capUnder(budget int) int {
36 return max(BatchCapFloor, (budget-1-e.Fixed)/(e.Row+e.Code))
37}
38
39var batchCodeSizes = struct {
40 sync.Mutex
41 byURLLength map[int]int
42}{byURLLength: map[int]int{}}
43
44func batchSampleToken(rng *rand.Rand) string {
45 raw := make([]byte, 32)
46 for i := range raw {
47 raw[i] = byte(rng.UintN(256))
48 }
49 return base64.RawURLEncoding.EncodeToString(raw)
50}
51
52func batchInviteURL(domain, token string) string {
53 return "https://" + domain + InvitePath + token
54}
55
56func worstBatchView(domain, site string, n int) oneTimeBatchView {
57 name := invitationSite(site, domain)
58 b := OneTimeBatch{Expires: Ident(batchWorstExpires)}
59 for range n {
60 link := batchInviteURL(domain, strings.Repeat("A", 43))
61 b.Rows = append(b.Rows, OneTimeBatchRow{Label: strings.Repeat("&", batchLabelBytes), URL: Ident(link), Message: invitationMessage(name, batchWorstExpires, link)})
62 }
63 for range oneTimeBadgesShown {
64 b.Groups = append(b.Groups, Ident(strings.Repeat("g", batchGroupNameBytes)))
65 }
66 v := newOneTimeBatchView(b)
67 v.GroupsMore = batchGroupsMore
68 return v
69}
70
71func (k *Kit) batchWeight(v oneTimeBatchView) (int, error) {
72 p := k.adminPage(v)
73 body, err := k.execute("onetime-batch", p)
74 return len(body) + k.linkedBytes(p), err
75}
76
77func (k *Kit) batchCodeSize(urlLength int) (int, error) {
78 batchCodeSizes.Lock()
79 size, ok := batchCodeSizes.byURLLength[urlLength]
80 batchCodeSizes.Unlock()
81 if ok {
82 return size, nil
83 }
84 domain := strings.Repeat("d", urlLength-len(batchInviteURL("", strings.Repeat("A", 43))))
85 rng := rand.New(rand.NewPCG(batchCapSeed, uint64(urlLength)))
86 sizes := make([]int, 0, batchCapSamples)
87 for range batchCapSamples {
88 link := batchInviteURL(domain, batchSampleToken(rng))
89 v := newOneTimeBatchView(OneTimeBatch{Rows: []OneTimeBatchRow{{URL: Ident(link)}}})
90 without, err := k.batchWeight(v.withCodes(nil, 0))
91 if err != nil {
92 return 0, err
93 }
94 code, err := qrsvg.Encode(link)
95 if err != nil {
96 return 0, err
97 }
98 with, err := k.batchWeight(v.withCodes([]*qrsvg.Symbol{&code}, 1))
99 if err != nil {
100 return 0, err
101 }
102 sizes = append(sizes, with-without)
103 }
104 slices.Sort(sizes)
105 size = sizes[len(sizes)/2]
106 batchCodeSizes.Lock()
107 batchCodeSizes.byURLLength[urlLength] = size
108 batchCodeSizes.Unlock()
109 return size, nil
110}
111
112func (k *Kit) measureBatchCap(domain, site string) (batchCapEstimate, error) {
113 fixed := func(n int) oneTimeBatchView {
114 v := worstBatchView(domain, site, n).withCodes(nil, 0)
115 v.LeftOut = batchLeftOut
116 return v
117 }
118 one, err := k.batchWeight(fixed(1))
119 if err != nil {
120 return batchCapEstimate{}, err
121 }
122 two, err := k.batchWeight(fixed(2))
123 if err != nil {
124 return batchCapEstimate{}, err
125 }
126 code, err := k.batchCodeSize(len(batchInviteURL(domain, strings.Repeat("A", 43))))
127 if err != nil {
128 return batchCapEstimate{}, err
129 }
130 e := batchCapEstimate{Row: two - one, Code: code}
131 e.Fixed = one - e.Row
132 e.Cap = e.capUnder(pageBudget)
133 return e, nil
134}
135
136func (k *Kit) InvitationBatchCap() int {
137 return k.batchCap
138}