Repository navigation
Expand file tree
/
Copy pathmodels.go
More file actions
343 lines (303 loc) · 11.7 KB
/
Copy pathmodels.go
File metadata and controls
343 lines (303 loc) · 11.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
package edeka
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
)
// Edeka is the main struct that holds the configuration and credentials for the API.
// All methods apart from registration/login are called on this struct.
//
// The embedded OffersClient is how *Edeka exposes GetOffers: a full-credential
// instance can call every method that *OffersClient has without an explicit
// hop through a nested field. That embedding is by value so every *Edeka has
// its own bearer cache, independent of any standalone *OffersClient. The
// HTTPDoer stored on the embedded OffersClient doubles as the SOAP client
// for every *Edeka call (receipts, markets, auth) - there is no second
// client field on Edeka itself.
//
// Concurrency: GetOffers, GetOfferImage, and the underlying bearer refresh
// (inherited from the embedded OffersClient) are safe to call from multiple
// goroutines on the same instance - the bearer cache is mutex-protected.
// Everything else (GetReceipts, GetReceipt, GetAllReceipts, FindMarkets,
// Reauthenticate, and the config mutators WithClient / SetReceiptPageSize /
// SetReceiptDelay) is NOT safe for concurrent use. Callers that only need
// concurrent access to the anonymous offers API should use NewOffersClient
// directly instead.
type Edeka struct {
OffersClient
config Config
creds Credentials
// temporaryCreds is true when creds came from anonymous registration.
// Temporary credentials can't fetch receipts and can't be saved via SaveAuth.
temporaryCreds bool
// pageSize controls receipt pagination size. Zero means use the default (50).
// Set via SetReceiptPageSize.
pageSize int
// delay controls the sleep between receipt pagination requests. Interpreted
// only when delaySet is true - that lets SetReceiptDelay(0) mean "no delay"
// without colliding with the unset-default zero value.
delay time.Duration
delaySet bool
}
func newEdeka(config Config, creds Credentials, client HTTPDoer) *Edeka {
return &Edeka{
OffersClient: OffersClient{endpoints: defaultEndpoints, client: client},
config: config,
creds: creds,
}
}
// WithClient sets the HTTP client and returns the receiver for chaining.
// Passing nil (or never calling this) uses the library's default client,
// which applies a 30-second timeout. To opt out of the timeout, pass
// http.DefaultClient or a custom client with no Timeout set.
// Must not be called concurrently with in-flight requests.
func (e *Edeka) WithClient(c HTTPDoer) *Edeka {
e.client = c
return e
}
// SaveAuth saves the current configuration and credentials to edeka_auth.json
// in the CURRENT WORKING DIRECTORY at call time. A process invoked from a
// different directory will write to a different file. Use SaveAuthTo with an
// absolute path for stable storage.
func (e *Edeka) SaveAuth() error {
return e.SaveAuthTo(DefaultAuthFileName)
}
// SaveAuthTo saves the current configuration and credentials to the specified path.
// Returns an error if the credentials are temporary (from anonymous registration).
func (e *Edeka) SaveAuthTo(path string) error {
if e.temporaryCreds {
return errors.New("cannot save temporary credentials (use CredentialsFromBearer to get permanent credentials)")
}
var auth AuthFile
auth.fromData(e.config, e.creds)
return auth.saveToPath(path)
}
// Config returns the configuration used by this instance.
func (e *Edeka) Config() Config { return e.config }
// Credentials returns the API credentials used by this instance.
// The returned struct includes the cleartext Password - intended for
// save/export flows that need the raw pair. Logging Credentials directly
// goes through its redacting String(); callers that log via struct access
// must treat the Password as secret.
func (e *Edeka) Credentials() Credentials { return e.creds }
func (e *Edeka) effectiveReceiptDelay() time.Duration {
if !e.delaySet {
return defaultReceiptDelay
}
return e.delay
}
// mobileContext builds the country/language pair that every SOAP data call
// embeds in its request body, lifted off the instance's Config.
func (e *Edeka) mobileContext() mobileContext {
return mobileContext{
CountryCode: e.config.CountryCode,
LanguageCode: e.config.LanguageCode,
}
}
// SetReceiptPageSize sets the number of receipts requested per pagination page.
// Must be in range [1, 500]. Zero or unset falls back to the default (50).
func (e *Edeka) SetReceiptPageSize(n int) error {
if n < 1 || n > 500 {
return fmt.Errorf("receipt page size must be in [1, 500], got %d", n)
}
e.pageSize = n
return nil
}
// SetReceiptDelay sets the sleep between receipt pagination requests.
// SetReceiptDelay(0) means "no delay". Negative values are rejected.
// If never called, the default (1500ms) is used.
func (e *Edeka) SetReceiptDelay(d time.Duration) error {
if d < 0 {
return fmt.Errorf("receipt delay must be >= 0, got %s", d)
}
e.delay = d
e.delaySet = true
return nil
}
// ------------------------------------------------------------------------------
// Device and Header Configuration
// ------------------------------------------------------------------------------
// DeviceConfig holds the device information that is sent to the API during registration and checkins.
type DeviceConfig struct {
DeviceID string `json:"device_id"`
Manufacturer string `json:"manufacturer"`
Model string `json:"model"`
}
// Random generates a random IMEI with a valid Luhn check digit
// and selects a random device from a predefined list.
func (dc *DeviceConfig) Random() {
dc.DeviceID = generateIMEI()
dc.Manufacturer, dc.Model = getRandomDevice()
}
// requiredField pairs a field's human-readable name with its current value.
// Used by requireNonEmpty so Verify methods can list their required fields
// declaratively instead of repeating the same loop.
type requiredField struct{ name, val string }
// requireNonEmpty returns an error naming the first zero-valued field.
// context is the label for the entity being validated ("config", "auth file").
func requireNonEmpty(context string, fields []requiredField) error {
for _, f := range fields {
if f.val == "" {
return fmt.Errorf("%s missing required field: %s", context, f.name)
}
}
return nil
}
// Config holds header values the API expects in every request
type Config struct {
DeviceConfig
MobileApplication string
Brand string
AppVersion string
Platform string
PlatformVersion string
CountryCode string
LanguageCode string
}
// Verify checks if all required fields are set.
func (c *Config) Verify() error {
return requireNonEmpty("config", []requiredField{
{"MobileApplication", c.MobileApplication},
{"Brand", c.Brand},
{"AppVersion", c.AppVersion},
{"Platform", c.Platform},
{"PlatformVersion", c.PlatformVersion},
{"DeviceID", c.DeviceID},
{"Manufacturer", c.Manufacturer},
{"Model", c.Model},
{"CountryCode", c.CountryCode},
{"LanguageCode", c.LanguageCode},
})
}
// FillDefaults sets default values for header fields that are still zero-valued.
// It does not set DeviceConfig (DeviceID, Manufacturer, Model) - call
// DeviceConfig.Random() or load from an auth file separately.
func (c *Config) FillDefaults() {
if c.MobileApplication == "" {
c.MobileApplication = "OVERALLKBP"
}
if c.Brand == "" {
c.Brand = "EDEKA"
}
if c.AppVersion == "" {
c.AppVersion = defaultAppVersion
}
if c.Platform == "" {
c.Platform = "ANDROID"
}
if c.PlatformVersion == "" {
c.PlatformVersion = defaultPlatformVersion
}
if c.CountryCode == "" {
c.CountryCode = "DE"
}
if c.LanguageCode == "" {
c.LanguageCode = "de"
}
}
// ------------------------------------------------------------------------------
// Authentication File
// ------------------------------------------------------------------------------
// AuthFile stores the device configuration and generated API token pair on the filesystem in cleartext.
// This is required because otherwise the user would have to provide a valid oauth bearer, or the software
// would have to register for an anonymous account every time it is started. To prevent spam, this file is written.
//
// Attention: Anyone with access to this file can query the API in your name.
type AuthFile struct {
DeviceConfig DeviceConfig `json:"device_config"`
SoapCredentials Credentials `json:"soap_credentials"`
}
func (a *AuthFile) fromData(config Config, creds Credentials) {
a.DeviceConfig = config.DeviceConfig
a.SoapCredentials = creds
}
// Verify checks if all required fields are set.
func (a *AuthFile) Verify() error {
return requireNonEmpty("auth file", []requiredField{
{"DeviceID", a.DeviceConfig.DeviceID},
{"Manufacturer", a.DeviceConfig.Manufacturer},
{"Model", a.DeviceConfig.Model},
{"Username", a.SoapCredentials.Username},
{"Password", a.SoapCredentials.Password},
})
}
func (a *AuthFile) saveToPath(path string) error {
if err := a.Verify(); err != nil {
return fmt.Errorf("auth file incomplete: %w", err)
}
jsonData, err := json.MarshalIndent(a, "", " ")
if err != nil {
return fmt.Errorf("marshaling auth file: %w", err)
}
// Write atomically: a crash mid-write would otherwise leave a corrupted file
// that forces the user through the whole bearer-exchange flow again.
// os.CreateTemp creates with mode 0600 on Unix; file-mode bits are ignored
// on Windows (ACL inherited from parent).
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0700); err != nil {
return fmt.Errorf("creating auth file directory: %w", err)
}
tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp-*")
if err != nil {
return fmt.Errorf("creating temp auth file: %w", err)
}
tmpPath := tmp.Name()
// If anything below fails, remove the temp file. Safe to call after a
// successful rename - os.Remove returns an error we can ignore for the
// "already gone" case.
defer func() {
_ = os.Remove(tmpPath)
}()
if _, err := tmp.Write(jsonData); err != nil {
tmp.Close()
return fmt.Errorf("writing temp auth file: %w", err)
}
// Flush to disk before rename so a crash between write and rename can't
// leave a zero-length file on filesystems that don't order data+metadata.
if err := tmp.Sync(); err != nil {
tmp.Close()
return fmt.Errorf("syncing temp auth file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("closing temp auth file: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
return fmt.Errorf("renaming temp auth file: %w", err)
}
return nil
}
// LoadFromFilepath reads and validates the AuthFile from the given path.
// Returns an error if the file is missing required fields after unmarshaling.
func (a *AuthFile) LoadFromFilepath(path string) error {
jsonData, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("reading auth file: %w", err)
}
if err = json.Unmarshal(jsonData, a); err != nil {
return fmt.Errorf("unmarshaling auth file: %w", err)
}
if err = a.Verify(); err != nil {
return fmt.Errorf("auth file %s: %w", path, err)
}
return nil
}
// ------------------------------------------------------------------------------
// Misc. Helpers
// ------------------------------------------------------------------------------
// Credentials holds the API token pair used for authentication.
// The field names match the XML tags from the API response.
type Credentials struct {
Username string `xml:"username" json:"username"`
Password string `xml:"password" json:"password"`
}
// String renders the Credentials with the password redacted. Keeps
// fmt.Printf("%v", creds) or incidental logging from leaking the secret
// into stdout/log files. %#v and struct field access still expose the
// raw password - callers that need to log credentials deliberately must
// reach for those explicitly.
func (c Credentials) String() string {
return fmt.Sprintf("{Username:%s Password:***}", c.Username)
}