• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Freegle / iznik-server / #2578

21 Jan 2026 09:33PM UTC coverage: 85.933% (+0.02%) from 85.913%
#2578

push

edwh
fix: Exclude noreply@ilovefreegle.org from chat review email detection

The chat review system was incorrectly flagging messages containing
noreply@ilovefreegle.org as suspicious. This is a system address that
appears in automated emails and should not trigger review.

The issue was that Mail::ourDomain() only checks OURDOMAINS which
includes subdomains (users.ilovefreegle.org, groups.ilovefreegle.org)
but not the base domain (ilovefreegle.org).

Added specific exclusion for noreply@ addresses on ilovefreegle.org
while ensuring noreply@external.com addresses still trigger review.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

6 of 6 new or added lines in 1 file covered. (100.0%)

51 existing lines in 4 files now uncovered.

25529 of 29708 relevant lines covered (85.93%)

30.62 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

54.93
/include/message/Attachment.php
1
<?php
2

3
namespace Freegle\Iznik;
4

5

6
use Jenssegers\ImageHash\ImageHash;
7
use GeminiAPI\Client;
8
use GeminiAPI\Enums\MimeType;
9
use GeminiAPI\Resources\ModelName;
10
use GeminiAPI\Resources\Parts\TextPart;
11
use GeminiAPI\Resources\Parts\ImagePart;
12

13
# TODO:
14
# - retire archiving
15

16
# This is a base class
17
class Attachment {
18
    /** @var  $dbhr LoggedPDO */
19
    private $dbhr;
20
    /** @var  $dbhm LoggedPDO */
21
    private $dbhm;
22
    private $id, $table, $hash, $archived, $externalurl, $externaluid, $externalmods;
23

24
    /**
25
     * @return null
26
     */
27
    public function getId() {
28
        return $this->id;
1✔
29
    }
30

31
    const TYPE_MESSAGE = 'Message';
32
    const TYPE_GROUP = 'Group';
33
    const TYPE_NEWSLETTER = 'Newsletter';
34
    const TYPE_COMMUNITY_EVENT = 'CommunityEvent';
35
    const TYPE_CHAT_MESSAGE = 'ChatMessage';
36
    const TYPE_USER = 'User';
37
    const TYPE_NEWSFEED = 'Newsfeed';
38
    const TYPE_VOLUNTEERING = 'Volunteering';
39
    const TYPE_STORY = 'Story';
40
    const TYPE_NOTICEBOARD = 'Noticeboard';
41

42
    /**
43
     * @return mixed
44
     */
45
    public function getHash() {
46
        return $this->hash;
10✔
47
    }
48

49
    public function getExternalUid() {
50
        return $this->externaluid;
2✔
51
    }
52

53
    public function getExternalMods() {
54
        return $this->externalmods;
2✔
55
    }
56

57
    public function getExternalUrl() {
58
        return $this->externalurl;
2✔
59
    }
60

61
    private function getImageDeliveryUrl($uid, $mods) {
62
        $p = strrpos($uid, 'freegletusd-');
43✔
63
        $url = NULL;
43✔
64

65
        if ($p !== FALSE) {
43✔
66
            if (IMAGE_DELIVERY) {
42✔
67
                $url = IMAGE_DELIVERY . "?";
42✔
68
                $mods = $mods ? json_decode($mods, TRUE) : [];
42✔
69

70
                if (Utils::pres('rotate', $mods)) {
42✔
71
                    $url .= 'ro=' . $mods['rotate'] . "&";
1✔
72
                }
73

74
                $url .= "url=" . TUS_UPLOADER . "/" . substr($uid, $p + strlen('freegletusd-')) . "/";
42✔
75
            } else {
UNCOV
76
                $url = TUS_UPLOADER . "/" . substr($uid, $p + strlen('freegletusd-')) . "/";
×
77
            }
78
        }
79

80
        return $url;
43✔
81
    }
82

83
    private function getExternalImageDeliveryUrl($externalurl, $mods) {
84
        $url = IMAGE_DELIVERY . "?";
1✔
85
        $mods = json_decode($mods, TRUE);
1✔
86

87
        if (Utils::pres('rotate', $mods)) {
1✔
88
            $url .= 'ro=' . $mods['rotate'] . "&";
×
89
        }
90

91
        $url .= "url=" . urlencode($externalurl);
1✔
92

93
        return $url;
1✔
94
    }
95

96
    public function getPath($thumb = FALSE, $id = null, $archived = FALSE, $mods = NULL) {
97
        if ($this->externaluid) {
39✔
98
            return $this->getImageDeliveryUrl($this->externaluid, $mods ? $mods : $this->externalmods);
36✔
99
        }
100

101
        if ($this->externalurl) {
4✔
102
            return $this->getExternalImageDeliveryUrl($this->externalurl, $this->externalmods);
1✔
103
        }
104

105
        # We serve up our attachment names as though they are files.
106
        # When these are fetched it will go through image.php
107
        $id = $id ? $id : $this->id;
3✔
108

109
        switch ($this->type) {
3✔
110
            case Attachment::TYPE_MESSAGE:
111
                $name = 'img';
2✔
112
                break;
2✔
113
            case Attachment::TYPE_GROUP:
114
                $name = 'gimg';
1✔
115
                break;
1✔
116
            case Attachment::TYPE_NEWSLETTER:
117
                $name = 'nimg';
×
118
                break;
×
119
            case Attachment::TYPE_COMMUNITY_EVENT:
120
                $name = 'cimg';
×
121
                break;
×
122
            case Attachment::TYPE_VOLUNTEERING:
123
                $name = 'oimg';
×
124
                break;
×
125
            case Attachment::TYPE_CHAT_MESSAGE:
126
                $name = 'mimg';
×
127
                break;
×
128
            case Attachment::TYPE_USER:
129
                $name = 'uimg';
×
130
                break;
×
131
            case Attachment::TYPE_NEWSFEED:
132
                $name = 'fimg';
×
133
                break;
×
134
            case Attachment::TYPE_STORY:
135
                $name = 'simg';
×
136
                break;
×
137
            case Attachment::TYPE_NOTICEBOARD:
138
                $name = 'bimg';
×
139
                break;
×
140
        }
141

142
        $name = $thumb ? "t$name" : $name;
3✔
143
        $domain = ($this->archived || $archived) ? IMAGE_ARCHIVED_DOMAIN : IMAGE_DOMAIN;
3✔
144

145
        return ("https://$domain/{$name}_$id.jpg");
3✔
146
    }
147

148
    public function getPublic() {
149
        $ret = array(
12✔
150
            'id' => $this->id,
12✔
151
            'hash' => $this->hash,
12✔
152
            $this->idatt => $this->{$this->idatt}
12✔
153
        );
12✔
154

155
        $ret['path'] = $this->getPath(FALSE);
12✔
156
        $ret['paththumb'] = $this->getPath(TRUE);
12✔
157
        $ret['mods'] = $this->externalmods;
12✔
158

159
        return ($ret);
12✔
160
    }
161

162
    function __construct(LoggedPDO $dbhr, LoggedPDO $dbhm, $id = null, $type = Attachment::TYPE_MESSAGE, $atts = null) {
163
        $this->dbhr = $dbhr;
333✔
164
        $this->dbhm = $dbhm;
333✔
165
        $this->id = $id;
333✔
166
        $this->type = $type;
333✔
167
        $this->archived = FALSE;
333✔
168
        $url = '';
333✔
169
        $this->uidname = 'externaluid';
333✔
170
        $this->modsname = 'externalmods';
333✔
171
        $uid = ', externaluid';
333✔
172
        $mods = ', externalmods';
333✔
173

174
        switch ($type) {
175
            case Attachment::TYPE_MESSAGE:
176
            {
233✔
177
                $this->table = 'messages_attachments';
233✔
178
                $this->idatt = 'msgid';
233✔
179
                $this->externalurlname = 'externalurl';
233✔
180
                $url = ', externalurl';
233✔
181
                break;
233✔
182
            }
233✔
183
            case Attachment::TYPE_GROUP:
184
                $this->table = 'groups_images';
209✔
185
                $this->idatt = 'groupid';
209✔
186
                break;
209✔
187
            case Attachment::TYPE_NEWSLETTER:
188
                $this->table = 'newsletters_images';
1✔
189
                $this->idatt = 'articleid';
1✔
190
                break;
1✔
191
            case Attachment::TYPE_COMMUNITY_EVENT:
192
                $this->table = 'communityevents_images';
1✔
193
                $this->idatt = 'eventid';
1✔
194
                break;
1✔
195
            case Attachment::TYPE_VOLUNTEERING:
196
                $this->table = 'volunteering_images';
1✔
197
                $this->idatt = 'opportunityid';
1✔
198
                break;
1✔
199
            case Attachment::TYPE_CHAT_MESSAGE:
200
                $this->table = 'chat_images';
8✔
201
                $this->idatt = 'chatmsgid';
8✔
202
                break;
8✔
203
            case Attachment::TYPE_USER:
204
            {
×
205
                $this->table = 'users_images';
×
206
                $this->idatt = 'userid';
×
207
                $this->externalurlname = 'url';
×
208
                $url = ', url';
×
209
                break;
×
210
            }
×
211
            case Attachment::TYPE_NEWSFEED:
212
                $this->table = 'newsfeed_images';
1✔
213
                $this->idatt = 'newsfeedid';
1✔
214
                break;
1✔
215
            case Attachment::TYPE_STORY:
216
                $this->table = 'users_stories_images';
×
217
                $this->idatt = 'storyid';
×
218
                break;
×
219
            case Attachment::TYPE_NOTICEBOARD:
220
                $this->table = 'noticeboards_images';
1✔
221
                $this->idatt = 'noticeboardid';
1✔
222
                break;
1✔
223
        }
224

225
        if ($id) {
333✔
226
            $sql = "SELECT {$this->idatt}, hash, archived $url $uid $mods FROM {$this->table} WHERE id = ?;";
32✔
227
            $as = $atts ? [$atts] : $this->dbhr->preQuery($sql, [$id]);
32✔
228
            foreach ($as as $att) {
32✔
229
                $this->hash = $att['hash'];
32✔
230
                $this->archived = $att['archived'];
32✔
231
                $this->externalurl = Utils::presdef($this->externalurlname, $att, null);
32✔
232
                $this->externaluid = Utils::presdef($this->uidname, $att, null);
32✔
233
                $this->externalmods = Utils::presdef($this->modsname, $att, null);
32✔
234
                $this->{$this->idatt} = $att[$this->idatt];
32✔
235
            }
236
        }
237
    }
238

239
    public function create($id, $data, $uid = NULL, $url = null, $stripExif = TRUE, $mods = NULL, $hash = NULL) {
240
        if ($hash) {
53✔
241
            $this->hash = $hash;
2✔
242
        }
243

244
        if ($url && !$this->externalurlname) {
53✔
245
            # We need to fetch the data from an external URL, because there is no attribute in this table to
246
            # store the external url.
247
            $ctx = stream_context_create(['http' =>
×
248
                [
×
249
                    'timeout' => 120
×
250
                ]
×
251
             ]);
×
252

253
            $data = @file_get_contents($url, FALSE, $ctx);
×
254
        }
255

256
        if (!$uid && $data) {
53✔
257
            # We have the literal data.  We want to avoid uploading the same image multiple times - something
258
            # which is particularly likely to happen with TN because it crossposts a lot and each separate message
259
            # (from our p.o.v.) contains a link to the same images.  We do this by doing a perceptual hash of the
260
            # image and having a local dirty cache of hashes we've seen before and the corresponding uploaded.
261
            # uid.  We rely on servers being rebooted before this gets too large.
262
            #
263
            # We use a simplistic file lock to serialise uploads so that this caching works.  It's common for us
264
            # to receive multiple emails from TN with the same images simultaneously.
265
            $fn = '/tmp/iznik.uploadlock';
50✔
266

267
            if (!file_exists($fn)) {
50✔
268
                touch($fn);
×
269
            }
270

271
            $fh = fopen($fn, 'r+');
50✔
272

273
            if ($fh) {
50✔
274
                if (!flock($fh, LOCK_EX)) {
50✔
275
                    error_log("Failed to lock upload file");
×
276
                    throw new \Exception("Failed to lock upload file");
50✔
277
                }
278
            } else {
279
                error_log("Failed to open upload file " . json_encode(error_get_last()));
×
280
                throw new \Exception("Failed to open upload file "  . json_encode(error_get_last()));
×
281
            }
282

283
            $hasher = new ImageHash;
50✔
284
            $img = @imagecreatefromstring($data);
50✔
285
            $uid = NULL;
50✔
286
            $fn = NULL;
50✔
287

288
            if ($img) {
50✔
289
                $this->hash = $hasher->hash($img)->toHex();
50✔
290
                $fn = "/tmp/imagehash-{$this->hash}";
50✔
291

292
                if (file_exists($fn)) {
50✔
293
                    $uid = file_get_contents($fn);
32✔
294
                    #error_log("Hash match on {$this->hash} for $id gives $uid");
295
                }
296
            }
297

298
            if (!$uid) {
50✔
299
                # No match - upload.
300
                $t = new Tus();
20✔
301
                $url = $t->upload(NULL, 'image/jpeg', $data);
20✔
302
                $uid = 'freegletusd-' . basename($url);
20✔
303
                file_put_contents($fn, $uid);
20✔
304
                #error_log("Uploaded to TUS $uid len " . strlen($data));
305
            }
306

307
            if ($fh) {
50✔
308
                flock($fh, LOCK_UN);
50✔
309
                fclose($fh);
50✔
310
            }
311
        }
312

313
        if ($uid) {
53✔
314
            # We now have an image uploaded.
315
            $rc = $this->dbhm->preExec(
52✔
316
                "INSERT INTO {$this->table} (`{$this->idatt}`, `{$this->uidname}`, `{$this->modsname}`, `hash`) VALUES (?, ?, ?, ?);",
52✔
317
                [
52✔
318
                    $id,
52✔
319
                    $uid,
52✔
320
                    json_encode($mods),
52✔
321
                    $this->hash,
52✔
322
                ]
52✔
323
            );
52✔
324

325
            $imgid = $rc ? $this->dbhm->lastInsertId() : null;
52✔
326

327
            if ($imgid) {
52✔
328
                $this->id = $imgid;
52✔
329
                $this->externaluid = $uid;
52✔
330
                $this->externalmods = $mods;
52✔
331
                $this->externalurl = $url;
52✔
332
            }
333

334
            return ([$imgid, $uid]);
52✔
335
        } else if ($this->externalurlname && $url) {
1✔
336
            $rc = $this->dbhm->preExec(
1✔
337
                "INSERT INTO {$this->table} (`{$this->idatt}`, `{$this->externalurlname}`) VALUES (?, ?);",
1✔
338
                [
1✔
339
                    $id,
1✔
340
                    $url,
1✔
341
                ]
1✔
342
            );
1✔
343

344
            $imgid = $rc ? $this->dbhm->lastInsertId() : null;
1✔
345

346
            if ($imgid) {
1✔
347
                $this->id = $imgid;
1✔
348
                $this->externalurl = $url;
1✔
349
            }
350

351
            return ([$imgid, NULL]);
1✔
352
        }
353

354
        return NULL;
×
355
    }
356

357
    public function getById($id) {
358
        $urlq = $this->externalurlname ? " OR {$this->externalurlname} IS NOT NULL" : '';
31✔
359
        $sql = "SELECT id FROM {$this->table} WHERE {$this->idatt} = ? AND ((data IS NOT NULL AND LENGTH(data) > 0) OR archived = 1 OR externaluid IS NOT NULL $urlq) ORDER BY id;";
31✔
360
        $atts = $this->dbhr->preQuery($sql, [$id]);
31✔
361
        $ret = [];
31✔
362
        foreach ($atts as $att) {
31✔
363
            $ret[] = new Attachment($this->dbhr, $this->dbhm, $att['id']);
6✔
364
        }
365

366
        return ($ret);
31✔
367
    }
368

369
    public function getByIds($ids) {
370
        $ret = [];
114✔
371
        $urlq = $this->externalurlname ? " OR {$this->externalurlname} IS NOT NULL" : '';
114✔
372

373
        if (count($ids)) {
114✔
374
            $sql = "SELECT id, {$this->idatt}, hash, archived, externaluid, externalmods, externalurl FROM {$this->table} 
111✔
375
                       WHERE {$this->idatt} IN (" . implode(',', $ids) . ") 
111✔
376
                       AND ((data IS NOT NULL AND LENGTH(data) > 0) OR archived = 1 OR externaluid IS NOT NULL $urlq) 
111✔
377
                       ORDER BY `primary` DESC, id;";
111✔
378
            #error_log($sql);
379
            $atts = $this->dbhr->preQuery($sql);
111✔
380
            foreach ($atts as $att) {
111✔
381
                $ret[] = new Attachment($this->dbhr, $this->dbhm, $att['id'], $this->type, $att);
8✔
382
            }
383
        }
384

385
        return ($ret);
114✔
386
    }
387

388
    public function getByImageIds($ids) {
389
        $ret = [];
2✔
390
        $urlq = $this->externalurlname ? " OR {$this->externalurlname} IS NOT NULL" : '';
2✔
391
        if (count($ids)) {
2✔
392
            $sql = "SELECT id, {$this->idatt}, hash, archived FROM {$this->table} WHERE id IN (" . implode(
2✔
393
                    ',',
2✔
394
                    $ids
2✔
395
                ) . ") AND ((data IS NOT NULL AND LENGTH(data) > 0) OR archived = 1 OR externaluid IS NOT NULL $urlq) ORDER BY id;";
2✔
396
            $atts = $this->dbhr->preQuery($sql);
2✔
397
            foreach ($atts as $att) {
2✔
398
                $ret[] = new Attachment($this->dbhr, $this->dbhm, $att['id'], $this->type, $att);
1✔
399
            }
400
        }
401

402
        return ($ret);
2✔
403
    }
404

405
    public function setData($data) {
406
        $this->dbhm->preExec("UPDATE {$this->table} SET archived = 0, data = ? WHERE id = ?;", [
×
407
            $data,
×
408
            $this->id
×
409
        ]);
×
410
    }
411

412
    public function fgc($url, $use_include_path, $ctx) {
413
        return @file_get_contents($url, $use_include_path, $ctx);
30✔
414
    }
415

416
    public function canRedirect($w = NULL, $h = NULL) {
417
        if ($this->externaluid) {
32✔
418
            # These are images uploaded to our current upload system, via tusd.
419
            return $this->getImageDeliveryUrl($this->externaluid, $this->externalmods);
32✔
420
        } else if ($this->externalurl) {
×
421
            # These are images hosted elsewhere, typically on TN or user profiles.
422
            return $this->getExternalImageDeliveryUrl($this->externalurl, $this->externalmods);
×
423
        } else {
424
            if ($this->archived) {
×
425
                # These are legacy images which have been archived to Azure storage.
426
                switch ($this->type) {
×
427
                    case Attachment::TYPE_MESSAGE:
428
                        $name = 'img';
×
429
                        break;
×
430
                    case Attachment::TYPE_CHAT_MESSAGE:
431
                        $name = 'mimg';
×
432
                        break;
×
433
                    case Attachment::TYPE_NEWSFEED:
434
                        $name = 'fimg';
×
435
                        break;
×
436
                    case Attachment::TYPE_COMMUNITY_EVENT:
437
                        $name = 'cimg';
×
438
                        break;
×
439
                    case Attachment::TYPE_NOTICEBOARD:
440
                        $name = 'bimg';
×
441
                        break;
×
442
                }
443

444
                $url = IMAGE_DELIVERY . "?";
×
445

446
                if ($w) {
×
447
                    $url .= "w=$w&";
×
448
                }
449

450
                if ($h) {
×
451
                    $url .= "h=$h&";
×
452
                }
453

454
                $url .= "url=" . urlencode("https://" . IMAGE_ARCHIVED_DOMAIN . "/{$name}_{$this->id}.jpg");
×
455

456
                return $url;
×
457
            }
458
        }
459

460
        return FALSE;
×
461
    }
462

463
    public function getData() {
464
        $ret = null;
31✔
465

466
        $url = $this->canRedirect();
31✔
467

468
        if ($url) {
31✔
469
            # This attachment has been archived out of our database, to a CDN.  Normally we would expect
470
            # that we wouldn't come through here, because we'd serve up an image link directly to the CDN, but
471
            # there is a timing window where we could archive after we've served up a link, so we have
472
            # to handle it.
473
            #
474
            # We fetch the data - not using SSL as we don't need to, and that host might not have a cert.  And
475
            # we put it back in the DB, because we are probably going to fetch it again.
476
            #
477
            # Apply a short timeout to avoid hanging the server if Azure is down.
478
            $ctx = stream_context_create(
30✔
479
                array(
30✔
480
                    'http' =>
30✔
481
                        array(
30✔
482
                            'timeout' => 2,
30✔
483
                        )
30✔
484
                )
30✔
485
            );
30✔
486

487
            $ret = $this->fgc($url, FALSE, $ctx);
30✔
488
        } else {
489
            $sql = "SELECT * FROM {$this->table} WHERE id = ?;";
1✔
490
            $datas = $this->dbhr->preQuery($sql, [$this->id]);
1✔
491

492
            foreach ($datas as $data) {
1✔
493
                $ret = $data['data'];
1✔
494
            }
495
        }
496

497
        return ($ret);
31✔
498
    }
499

500
    public function findWebReferences() {
501
        # Find a web page containing this imge, if any.
502
        $ret = null;
×
503

504
        if ($this->type == Attachment::TYPE_MESSAGE) {
×
505
            $data = $this->getData();
×
506
            $base64 = base64_encode($data);
×
507

508
            $r_json = '{
×
509
                "requests": [
510
                    {
511
                      "image": {
512
                        "content":"' . $base64 . '"
×
513
                      },
514
                      "features": [
515
                          {
516
                            "type": "WEB_DETECTION",
517
                            "maxResults": 1
518
                          }
519
                      ]
520
                    }
521
                ]
522
            }';
×
523

524
            $curl = curl_init();
×
525
            curl_setopt(
×
526
                $curl,
×
527
                CURLOPT_URL,
×
528
                'https://vision.googleapis.com/v1/images:annotate?key=' . GOOGLE_VISION_KEY
×
529
            );
×
530
            curl_setopt($curl, CURLOPT_RETURNTRANSFER, TRUE);
×
531
            curl_setopt($curl, CURLOPT_HTTPHEADER, array("Content-type: application/json"));
×
532
            curl_setopt($curl, CURLOPT_POST, TRUE);
×
533
            curl_setopt($curl, CURLOPT_POSTFIELDS, $r_json);
×
534
            $json_response = curl_exec($curl);
×
535
            $status = curl_getinfo($curl, CURLINFO_HTTP_CODE);
×
536

537
            if ($status) {
×
538
                $rsp = json_decode($json_response, TRUE);
×
539
                #error_log("Identified {$this->id} by Google $json_response for $r_json");
540
                error_log("Matching " . var_export($rsp, TRUE));
×
541

542
                if ($rsp &&
×
543
                    array_key_exists('responses', $rsp) &&
×
544
                    count($rsp['responses']) > 0 &&
×
545
                    array_key_exists('webDetection', $rsp['responses'][0]) &&
×
546
                    array_key_exists('pagesWithMatchingImages', $rsp['responses'][0]['webDetection'])) {
×
547
                    $rsps = $rsp['responses'][0]['webDetection']['pagesWithMatchingImages'];
×
548

549
                    foreach ($rsps as $r) {
×
550
                        if (array_key_exists('fullMatchingImages', $r) && strpos($r['url'], USER_SITE) === FALSE) {
×
551
                            $ret = $r['url'];
×
552
                        }
553
                    }
554
                }
555
            }
556

557
            curl_close($curl);
×
558
        }
559

560
        return ($ret);
×
561
    }
562

563
    public function ocr($data = null, $returnfull = FALSE, $video = FALSE) {
564
        # Identify text in an attachment using Google Vision API.
565
        $base64 = $data ? $data : base64_encode($this->getData());
×
566

567
        if ($video) {
×
568
//            "videoContext": {
569
//                "textDetectionConfig": {
570
//                    "languageHints": ["en"]
571
//                }
572
//              }
573
            $r_json = '{
×
574
              "inputContent": "' . $base64 . '",
×
575
              "features": ["TEXT_DETECTION"],
576
            }';
×
577
        } else {
578
            $r_json = '{
×
579
                "requests": [
580
                    {
581
                      "image": {
582
                        "content":"' . $base64 . '",
×
583
                      },
584
                      "features": [
585
                          {
586
                            "type": "TEXT_DETECTION"
587
                          }
588
                      ],
589
                      "imageContext": {
590
                        "languageHints": [
591
                          "en"
592
                        ]
593
                      }
594
                    }
595
                ]
596
            }';
×
597
        }
598

599
        $url = 'https://vision.googleapis.com/v1/images:annotate?key=' . GOOGLE_VISION_KEY;
×
600
        $curl = curl_init();
×
601
        curl_setopt($curl, CURLOPT_URL, $url);
×
602
        curl_setopt($curl, CURLOPT_RETURNTRANSFER, TRUE);
×
603
        curl_setopt($curl, CURLOPT_HTTPHEADER, array("Content-type: application/json"));
×
604
        curl_setopt($curl, CURLOPT_POST, TRUE);
×
605
        curl_setopt($curl, CURLOPT_POSTFIELDS, $r_json);
×
606

607
        if ($video) {
×
608
            curl_setopt($curl, CURLOPT_HTTPHEADER, array("Authorization: Bearer " . GOOGLE_VIDEO_KEY));
×
609
        }
610

611
        $json_response = curl_exec($curl);
×
612
        $status = curl_getinfo($curl, CURLINFO_HTTP_CODE);
×
613

614
        $text = '';
×
615
        $rsps = null;
×
616

617
        if ($status) {
×
618
            error_log("Rsp $json_response");
×
619
            $rsp = json_decode($json_response, TRUE);
×
620

621
            if ($rsp && array_key_exists('responses', $rsp) && count($rsp['responses']) > 0 && array_key_exists(
×
622
                    'textAnnotations',
×
623
                    $rsp['responses'][0]
×
624
                )) {
×
625
                $rsps = $rsp['responses'][0]['textAnnotations'];
×
626

627
                foreach ($rsps as $rsp) {
×
628
                    $text .= $rsp['description'] . "\n";
×
629
                    break;
×
630
                }
631
            }
632
        }
633

634
        curl_close($curl);
×
635

636
        return ($returnfull ? $rsps : $text);
×
637
    }
638

639
    public function setPrivate($att, $val) {
640
        $this->dbhm->preExec("UPDATE {$this->table} SET `$att` = ? WHERE id = {$this->id};", [$val]);
14✔
641
    }
642

643
    public function delete() {
644
        $this->dbhm->preExec("DELETE FROM {$this->table} WHERE id = {$this->id};");
2✔
645
    }
646

647
    public function getIdAtt() {
648
        return $this->idatt;
×
649
    }
650

651
    function rotate($rotate) {
652
        // Ensure $rotate is not negative.
653
        if ($this->externaluid || $this->externalurl) {
2✔
654
            # We can rotate this by changing external mods.
655
            $mods = json_decode($this->externalmods, TRUE);
2✔
656
            $rotate = ($rotate + 360) % 360;
2✔
657
            $mods['rotate'] = $rotate;
2✔
658
            $this->setPrivate('externalmods', json_encode($mods));
2✔
659
        } else {
660
            $data = $this->getData();
×
661
            $i = new Image($data);
×
662
            $i->rotate($rotate);
×
663
            $newdata = $i->getData(100);
×
664
            $this->setData($newdata);
×
665
        }
666

667
        if ($this->type == Attachment::TYPE_MESSAGE) {
2✔
668
            # Only some kinds of attachments record whether they are rotated.
669
            $this->recordRotate();
2✔
670
        }
671
    }
672

673
    public function recordRotate() {
674
        $this->setPrivate('rotated', 1);
2✔
675
    }
676

677
    public function recognise() {
678
        # Get external URL for image.  Use 768x768 as the maximum size because of how Gemini counts tokens..
679
        $url = $this->canRedirect(768, 768);
×
680
        $data = file_get_contents($url);
×
681

682
        $client = new Client(GOOGLE_GEMINI_API_KEY);
×
683
        $response = $client->withV1BetaVersion()
×
684
            ->generativeModel('gemini-2.0-flash-lite')
×
685
            ->withSystemInstruction(
×
686
                'Identify the primary item in each image. ' .
×
687
                'All items in images are likely to be second-hand household items.' .
×
688
                'Return all data in JSON format, as either strings or floats, with the following fields:' .
×
689
                'primaryItem, shortDescription, longDescription, approximateWeightInKg, size, condition, colour, estimatedValueInGBP, commonSynonyms, ElectricalItem, clarityOfImage.' .
×
690
                'In the JSON, use:' .
×
691
                'Great/OK/Poor for condition and clarityOfImage,' .
×
692
                'float for approximateWeightInKg,' .
×
693
                'short string for primaryItem,' .
×
694
                'float with an estimated secondhand eBay price in GBP estimatedValueInGBP,' .
×
695
                'the dimensions in wxhxd format in cm of a box which would contain the item for size' .
×
696
                '.  Write the description from the pov of the person who is giving away the item, friendly tone but stay factual and only add information that matches the image.'
×
697
            )->generateContent(
×
698
                new TextPart("Identify the image."),
×
699
                new ImagePart(MimeType::IMAGE_JPEG, base64_encode($data))
×
700
            );
×
701

702
        $text = $response->text();
×
703
        $json = substr($text, strpos($text, '{'), strrpos($text, '}') - strpos($text, '{') + 1);
×
704
        $jsond = json_decode($json, TRUE);
×
705

706
        if ($json && $jsond) {
×
707

708
            $this->dbhm->preExec("INSERT INTO messages_attachments_recognise (attid, info) VALUES (?, ?) ON DUPLICATE KEY UPDATE info = ?;", [
×
709
                $this->id,
×
710
                $json,
×
711
                $json
×
712
            ]);
×
713
        }
714

715
        return $jsond;
×
716
    }
717
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc