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

MLTSHP / mltshp / 0194c357-e9c9-4f7d-a6ee-0fdaa9ffefaf

01 Feb 2025 09:18PM UTC coverage: 69.496% (-0.1%) from 69.598%
0194c357-e9c9-4f7d-a6ee-0fdaa9ffefaf

push

buildkite

4609 of 6632 relevant lines covered (69.5%)

0.69 hits per line

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

84.95
/models/sharedfile.py
1
import re
1✔
2
from os import path
1✔
3
import hashlib
1✔
4
from datetime import timedelta
1✔
5

6
from tornado import escape
1✔
7
from tornado.options import options
1✔
8

9
from lib.flyingcow import Model, Property
1✔
10
from lib.flyingcow.cache import ModelQueryCache
1✔
11
from lib.flyingcow.db import IntegrityError
1✔
12
from lib.utilities import base36encode, base36decode, pretty_date, s3_url, rfc822_date, utcnow
1✔
13

14
from . import user
1✔
15
from . import sourcefile
1✔
16
from . import fileview
1✔
17
from . import favorite
1✔
18
from . import shakesharedfile
1✔
19
from . import shake
1✔
20
from . import comment
1✔
21
from . import notification
1✔
22
from . import conversation
1✔
23

1✔
24
import models.post
1✔
25
import models.nsfw_log
1✔
26
import models.tag
1✔
27
import models.tagged_file
28

1✔
29
from tasks.timeline import add_posts, delete_posts
1✔
30
from tasks.counts import calculate_saves
1✔
31
from tasks.transcode import transcode_sharedfile
32

33

1✔
34
class Sharedfile(ModelQueryCache, Model):
1✔
35
    source_id = Property()
1✔
36
    user_id = Property()
1✔
37
    name = Property()
1✔
38
    title = Property()
1✔
39
    description = Property()
1✔
40
    alt_text = Property()
1✔
41
    source_url = Property()
1✔
42
    share_key = Property()
1✔
43
    content_type = Property()
1✔
44
    size = Property(default=0)
45
    # we set default to 0, since DB does not accept Null values
1✔
46
    like_count = Property(default=0)
1✔
47
    save_count = Property(default=0)
1✔
48
    view_count = Property(default=0)
1✔
49
    deleted = Property(default=0)
1✔
50
    parent_id = Property(default=0)
1✔
51
    original_id = Property(default=0)
1✔
52
    created_at = Property()
1✔
53
    updated_at = Property()
1✔
54
    activity_at = Property()
55

1✔
56
    def get_title(self, sans_quotes=False):
57
        """
58
        Returns title, escapes double quotes if sans_quotes is True, used
59
        for rendering title inside fields.
60
        """
1✔
61
        title = self.title
1✔
62
        if title is None:
63
            title = ''
1✔
64
        if sans_quotes:
1✔
65
            title = re.sub('"', '"', title)
1✔
66
        return title.strip()
1✔
67

68
    def get_description(self, raw=False):
1✔
69
        """
70
        Returns desciption, escapes double quotes if sans_quotes is True, used
71
        for rendering description inside fields.
72
        """
73
        description = self.description
1✔
74
        if description is None:
1✔
75
            description = ''
1✔
76

77
        scheme = (options.use_cdn and "https") or "http"
1✔
78

79
        if not raw:
1✔
80
            extra_params = 'target="_blank" rel="nofollow"'
81

1✔
82
            description = escape.linkify(description, True,
83
                extra_params=extra_params)
84

85
            description = re.sub(
86
                r'(\A|\s)#(\w+)',
87
                r'\1<a href="' + scheme + '://' + options.app_host + r'/tag/\2">#\2</a>',
1✔
88
                description)
89
            description = description.replace('\n', '<br>')
1✔
90
        return description.strip()
1✔
91

92
    def get_alt_text(self, raw=False):
1✔
93
        """
94
        Returns alt text, converts new lines to <br> unless raw is True, used
95
        for rendering alt text inside textarea fields.
96
        """
97
        alt_text = self.alt_text
1✔
98
        if alt_text is None:
1✔
99
            alt_text = ''
1✔
100

101
        if not raw:
1✔
102
            alt_text = escape.xhtml_escape(alt_text)
1✔
103
            alt_text = alt_text.replace('\n', '<br>')
104

1✔
105
        return alt_text.strip()
106

1✔
107
    def save(self, *args, **kwargs):
108
        """
109
        Sets the dates before saving.
110
        """
1✔
111
        if options.readonly:
×
112
            self.add_error('_', 'Site is read-only.')
×
113
            return False
114

1✔
115
        self._set_dates()
1✔
116
        ignore_tags = False
117

118
        #we dont want to set tags if this is a save from a shared file
1✔
119
        if 'ignore_tags' in kwargs and kwargs['ignore_tags']:
1✔
120
            ignore_tags = True
121

1✔
122
        if 'ignore_tags' in kwargs:
1✔
123
            del(kwargs['ignore_tags'])
124

1✔
125
        super(Sharedfile, self).save(*args, **kwargs)
126

1✔
127
        if ignore_tags:
1✔
128
            return
129

130
        # clear out all tags
1✔
131
        all_tagged_files = models.TaggedFile.where('sharedfile_id = %s', self.id)
1✔
132
        for tf in all_tagged_files:
1✔
133
            tf.deleted = 1
1✔
134
            tf.save()
135

136
        # extract tags
1✔
137
        tags = self.find_tags()
1✔
138
        for t in tags:
1✔
139
            tag = models.Tag.get("name = %s", t)
1✔
140
            if not tag:
1✔
141
                tag = models.Tag(name=t)
1✔
142
                tag.save()
143

1✔
144
            tagged_file = models.TaggedFile.get('sharedfile_id = %s and tag_id = %s',
145
                self.id, tag.id)
1✔
146
            if tagged_file and tagged_file.deleted:
1✔
147
                tagged_file.deleted = 0
1✔
148
                tagged_file.save()
149
            else:
1✔
150
                tagged_file = models.TaggedFile(sharedfile_id=self.id,
151
                    tag_id = tag.id, deleted=0)
1✔
152
                tagged_file.save()
153

154

1✔
155
    def can_save(self, user_check=None):
156
        """
157
        Can only save the file if the user is different.
158

159
        Also, if we haven't already saved it.
160
        """
1✔
161
        if options.readonly:
×
162
            return False
1✔
163
        if not user_check:
1✔
164
            return False
1✔
165
        if self.user_id == user_check.id:
1✔
166
            return False
167
        else:
1✔
168
            return True
169

1✔
170
    def can_delete(self, user_check=None):
171
        """
172
        Can only delete if the file belongs to the user.
173
        """
1✔
174
        if options.readonly:
×
175
            return False
1✔
176
        if not user_check:
1✔
177
            return False
1✔
178
        if self.user_id == user_check.id:
1✔
179
            return True
180
        else:
1✔
181
            return False
182

1✔
183
    def can_favor(self, user_check=None):
184
        """
185
        Can favor any image a user hasn't favorited, except
186
        if it's your image.
187
        """
1✔
188
        if options.readonly:
×
189
            return False
1✔
190
        if not user_check:
1✔
191
            return False
1✔
192
        if self.user_id == user_check.id:
1✔
193
            return False
1✔
194
        return not user_check.has_favorite(self)
195

1✔
196
    def can_unfavor(self, user_check=None):
197
        """
198
        Any use can favorite if they've already favored.
199
        """
1✔
200
        if options.readonly:
×
201
            return False
1✔
202
        if not user_check:
1✔
203
            return False
1✔
204
        if self.user_id == user_check.id:
1✔
205
            return False
1✔
206
        return user_check.has_favorite(self)
207

1✔
208
    def can_edit(self, user_check=None):
209
        """
210
        Checks if a user can edit the sharedfile. Can only edit the shardfile
211
        if the sharedfile belongs to them.
212
        """
1✔
213
        if options.readonly:
×
214
            return False
1✔
215
        if not user_check:
1✔
216
            return False
1✔
217
        if self.user_id == user_check.id:
1✔
218
            return True
219
        else:
1✔
220
            return False
221

1✔
222
    def save_to_shake(self, for_user, to_shake=None):
223
        """
224
        Saves this file to a user's shake, or to the to_shake
225
        if it is provided.
226
        """
1✔
227
        new_sharedfile = Sharedfile()
1✔
228
        new_sharedfile.user_id = for_user.id
1✔
229
        new_sharedfile.name = self.name
1✔
230
        new_sharedfile.title = self.title
1✔
231
        new_sharedfile.content_type = self.content_type
1✔
232
        new_sharedfile.source_url = self.source_url
1✔
233
        new_sharedfile.source_id = self.source_id
1✔
234
        new_sharedfile.parent_id = self.id
1✔
235
        new_sharedfile.description = self.description
1✔
236
        new_sharedfile.alt_text = self.alt_text
237

1✔
238
        if self.original_id == 0:
1✔
239
            new_sharedfile.original_id = self.id
240
        else:
1✔
241
            new_sharedfile.original_id = self.original_id
1✔
242
        new_sharedfile.save(ignore_tags=True)
1✔
243
        new_sharedfile.share_key = base36encode(new_sharedfile.id)
1✔
244
        new_sharedfile.save(ignore_tags=True)
245

1✔
246
        if to_shake:
1✔
247
            shake_to_save = to_shake
248
        else:
1✔
249
            shake_to_save = for_user.shake()
1✔
250
        new_sharedfile.add_to_shake(shake_to_save)
251

252
        #create a notification to the sharedfile owner
1✔
253
        notification.Notification.new_save(for_user, self)
254

1✔
255
        calculate_saves.delay_or_run(self.id)
1✔
256
        return new_sharedfile
257

1✔
258
    def render_data(self, user=None, store_view=True):
1✔
259
        user_id = None
1✔
260
        if user:
×
261
            user_id = user.id
1✔
262
        source = self.sourcefile()
1✔
263
        oembed = escape.json_decode(source.data)
1✔
264
        if store_view:
×
265
            self.add_view(user_id)
266

1✔
267
        html = oembed['html'] or ""
268

269
        # force https for any URLs in the html
1✔
270
        html = html.replace('http://', 'https://')
271

1✔
272
        extra_attributes = ""
273

274
        # Allows iframe to trigger full-screen display
1✔
275
        if 'allowfullscreen' not in html:
1✔
276
            extra_attributes += " allowfullscreen"
277

278
        # Enable sandbox; only permit scripting (most rich embeds will need this)
279
        # allow-popups is needed for opening links to original content (ie, YouTube embeds)
280
        # allow-popups-to-escape-sandbox frees the popped up window from any
281
        # restrictions mltshp decides to enforce.
282
        # Related: https://github.com/MLTSHP/mltshp/issues/746
1✔
283
        if 'sandbox=' not in html:
1✔
284
            extra_attributes += ' sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-presentation"'
285

286
        # Prevent referrer leaks to third parties
1✔
287
        if 'referrerpolicy=' not in html:
1✔
288
            extra_attributes += ' referrerpolicy="no-referrer-when-downgrade"'
289

1✔
290
        if 'allow=' not in html:
1✔
291
            extra_attributes += ' allow="encrypted-media"'
292

293
        # Force lazy loading
1✔
294
        if 'loading=' not in html:
1✔
295
            extra_attributes += ' loading="lazy"'
296

297
        # Force low priority fetching
1✔
298
        if 'fetchpriority=' not in html:
1✔
299
            extra_attributes += ' fetchpriority="low"'
300

1✔
301
        if extra_attributes:
1✔
302
            html = html.replace('<iframe ', '<iframe ' + extra_attributes)
303

1✔
304
        html = html.replace('autoplay=0', '')
305

1✔
306
        return html
307

1✔
308
    def post_url(self, relative=False):
309
        if relative:
310
            return '/p/%s' % (self.share_key)
311
        else:
312
            scheme = (options.use_cdn and "https") or "http"
1✔
313
            return '%s://%s/p/%s' % (scheme, options.app_host, self.share_key)
1✔
314

315
    def as_json(self, user_context=None):
1✔
316
        """
1✔
317
        If user_context is provided, adds a couple of fields to
318
        the returned dict representation, such as 'saved' and 'liked'.
319
        """
320
        u = self.user()
321
        source = self.sourcefile()
322

323
        scheme = (options.use_cdn and "https") or "http"
324
        json_object = {
325
            'user': u.as_json(),
326
            'nsfw' : source.nsfw_bool(),
327
            'pivot_id' : self.share_key,
328
            'sharekey' : self.share_key,
329
            'name' : self.name,
330
            'views' : self.view_count,
331
            'likes' : self.like_count,
332
            'saves' : self.save_count,
333
            'comments' : self.comment_count(),
334
            'width' : source.width,
335
            'height' : source.height,
1✔
336
            'title' : self.title,
1✔
337
            'description' : self.description,
1✔
338
            'alt_text' : self.alt_text,
339
            'posted_at' : self.created_at.replace(microsecond=0, tzinfo=None).isoformat() + 'Z',
1✔
340
            'permalink_page' : self.post_url(),
×
341
        }
342

1✔
343
        if user_context:
1✔
344
            json_object['saved'] = bool(user_context.saved_sharedfile(self))
345
            json_object['liked'] = user_context.has_favorite(self)
1✔
346

347
        if(source.type == 'link'):
348
            json_object['url'] = self.source_url
349
        else:
1✔
350
            json_object['original_image_url'] = '%s://s.%s/r/%s' % (scheme, options.app_host, self.share_key)
351
        return json_object
1✔
352

353
    def sourcefile(self):
354
        """
355
        Returns sharedfile's Sourcefile.
356
        """
1✔
357
        return sourcefile.Sourcefile.get("id = %s", self.source_id)
×
358

1✔
359
    def can_user_delete_from_shake(self, user, from_shake):
1✔
360
        """
1✔
361
        A user can delete a sharedfile from a shake if they are the owner of the sharedfile
1✔
362
        or if they are the shake owner.
1✔
363
        """
364
        if options.readonly:
1✔
365
            return False
366
        if self.user_id == user.id:
367
            return True
368
        if from_shake.is_owner(user):
369
            return True
1✔
370
        return False
×
371

1✔
372
    def delete_from_shake(self, from_shake):
1✔
373
        """
×
374
        Removes a file from a shake.  Make sure we find the shakesharedfile entry and only mark it as
1✔
375
        deleted if it's in another shake (2 or more shakes when this action was initiated).
1✔
376
        """
1✔
377
        if options.readonly:
378
            return False
×
379
        ssf = shakesharedfile.Shakesharedfile.get("shake_id = %s and sharedfile_id = %s and deleted=0", from_shake.id, self.id)
380
        if not ssf:
1✔
381
            return False
382
        ssf.deleted = 1
383
        if ssf.save():
384
            return True
385
        else:
1✔
386
            return False
×
387

1✔
388
    def add_to_shake(self, to_shake):
1✔
389
        """
1✔
390
        Takes any shake and adds this shared file to it.
1✔
391
            - TODO: need to check if has permission
1✔
392
        """
1✔
393
        if options.readonly:
1✔
394
            return False
395
        ssf = shakesharedfile.Shakesharedfile.get("shake_id = %s and sharedfile_id = %s and deleted=0", to_shake.id, self.id)
1✔
396
        if not ssf:
397
            ssf = shakesharedfile.Shakesharedfile(shake_id=to_shake.id, sharedfile_id=self.id)
398
        ssf.deleted = 0
399
        ssf.save()
1✔
400
        if ssf.saved():
401
            add_posts.delay_or_run(shake_id=to_shake.id, sharedfile_id=self.id, sourcefile_id=self.source_id)
402

403
    def shakes(self):
404
        """
405
        The shakes this file is in.
406
        """
407
        select = """
1✔
408
            select shake.* from shake
409
            left join shakesharedfile on
1✔
410
            shakesharedfile.shake_id = shake.id
411
            where shake.deleted = 0
412
            and shakesharedfile.sharedfile_id = %s
413
            and shakesharedfile.deleted = 0;
1✔
414
        """
415
        return shake.Shake.object_query(select, self.id)
1✔
416

417
    def user(self):
418
        """
419
        Returns sharedfile's user.
1✔
420
        """
1✔
421
        return user.User.get("id = %s and deleted=0", self.user_id)
1✔
422

1✔
423
    def parent(self, include_deleted=False):
424
        """
1✔
425
        Returns the parent object if it's set, otherwise returns None.
1✔
426
        """
427
        if not bool(self.parent_id):
1✔
428
            return None
429
        if include_deleted:
430
            deleted_clause = ''
431
        else:
1✔
432
            deleted_clause = ' and deleted=0'
1✔
433
        return self.get("id = %s" + deleted_clause, self.parent_id)
1✔
434

1✔
435
    def original(self, include_deleted=False):
436
        """
1✔
437
        Returns the original object if it's set, otherwise returns None.
1✔
438
        """
439
        if not bool(self.original_id):
1✔
440
            return None
441
        if include_deleted:
442
            deleted_clause = ''
443
        else:
444
            deleted_clause = ' and deleted=0'
1✔
445
        return self.get("id = %s" + deleted_clause, self.original_id)
1✔
446

×
447
    def parent_user(self):
1✔
448
        """
449
        If a sharedfile has a parent_sharedfile_id set, returns user of the
1✔
450
        parent sharedfile.
451
        """
452
        parent = self.parent()
453
        if not parent:
454
            return None
1✔
455
        return parent.user()
1✔
456

×
457
    def original_user(self):
1✔
458
        """
459
        If a sharedfile has an original_id, this returns the user who
1✔
460
        originally shared that file
461
        """
462
        original = self.original()
463
        if not original:
1✔
464
            return None
×
465
        return original.user()
466

1✔
467
    def delete(self):
1✔
468
        """
469
        Sets the deleted flag to 1 and saves to DB.
1✔
470
        """
1✔
471
        if options.readonly:
1✔
472
            return False
1✔
473

474
        self.deleted = 1;
1✔
475
        self.save()
476

1✔
477
        tags = models.TaggedFile.where('sharedfile_id = %s', self.id)
1✔
478
        for tag in tags:
1✔
479
            tag.deleted = 1
1✔
480
            tag.save()
481

482
        delete_posts.delay_or_run(sharedfile_id=self.id)
1✔
483

1✔
484
        if self.original_id > 0:
485
            calculate_saves.delay_or_run(self.original_id)
1✔
486
        if self.parent_id > 0:
1✔
487
            calculate_saves.delay_or_run(self.original_id)
488

489
        #mute conversations
1✔
490
        conversations = conversation.Conversation.where('sharedfile_id = %s', self.id)
491
        [c.mute() for c in conversations]
492

493
        ssfs = shakesharedfile.Shakesharedfile.where('sharedfile_id = %s', self.id)
1✔
494
        [ssf.delete() for ssf in ssfs]
×
495

496

1✔
497
    def add_view(self, user_id=None):
1✔
498
        """
1✔
499
        Increments a view for the image.
500
        """
1✔
501
        if options.readonly:
502
            return False
503

504
        if not user_id:
1✔
505
            user_id = 0
506
        self.connection.execute("INSERT INTO fileview (user_id, sharedfile_id, created_at) VALUES (%s, %s, NOW())", user_id, self.id)
1✔
507

508
    def pretty_created_at(self):
509
        """
510
        A friendly version of the created_at date.
511
        """
1✔
512
        return pretty_date(self.created_at)
1✔
513

1✔
514
    def _set_dates(self):
515
        """
1✔
516
        Sets the created_at and updated_at fields. This should be something
517
        a subclass of Property that takes care of this during the save cycle.
518
        """
519
        if self.id is None or self.created_at is None:
1✔
520
            self.created_at = utcnow()
1✔
521
        self.updated_at = utcnow()
522

1✔
523
    def increment_view_count(self, amount):
524
        """
525
        Update view_count field for current sharedfile.
526
        """
1✔
527
        view_count = self.view_count or 0
528
        self.update_attribute('view_count', view_count + amount)
529

1✔
530
    def calculate_view_count(self, last_fileview=0):
531
        """
1✔
532
        Calculate count of all views for the sharedfile.
533
        """
534
        count = fileview.Fileview.query(
535
            """SELECT count(*) AS result_count FROM fileview
1✔
536
               WHERE sharedfile_id = %s and user_id != %s""", self.id, self.user_id)
1✔
537
        return int(count[0]['result_count'])
538

539
    def livish_view_count(self):
540
        """
×
541
        If a file is recent, show its live view count.
×
542
        """
543
        if utcnow() - self.created_at < timedelta(hours=24):
×
544
            return (self.view_count or 0) + self.calculate_view_count()
545
        else:
1✔
546
            # if a file is not recent and also has zero
547
            # then try to pull a live count anyway.
548
            if self.view_count == 0:
549
                return self.calculate_view_count()
×
550
            else:
×
551
                return self.view_count
×
552

553
    def saves(self):
×
554
        """
555
        Retrieve all saves of this file.
1✔
556
        """
557
        original =  self.where("original_id = %s and deleted = 0", self.id)
558
        if len(original) > 0:
559
            return original
×
560
        else:
561
            return self.where("parent_id = %s and deleted = 0", self.id)
1✔
562

563
    def favorites(self):
564
        """
565
        Retrieve all saves of this file.
566
        """
567
        return favorite.Favorite.where("sharedfile_id = %s and deleted = 0 ORDER BY id", self.id)
568

×
569
    def calculate_save_count(self):
×
570
        """
×
571
        Count of all saves for the images.  If the file is the original for other
572
        sharedfiles, then the save count is the total of all files where it's the
×
573
        original.  If the file is not an original, only count direct saves, ala
574
        parent_id.
1✔
575
        """
576
        original =  self.where_count("original_id = %s and deleted = 0", self.id)
577
        if original > 0:
578
            return original
×
579
        else:
580
            return self.where_count("parent_id = %s and deleted = 0", self.id)
1✔
581

582
    def calculate_like_count(self):
583
        """
584
        Count of all favorites, excluding deleted favorites.
1✔
585
        """
586
        return favorite.Favorite.where_count("sharedfile_id = %s and deleted = 0", self.id)
1✔
587

588
    def comment_count(self):
589
        """
590
        Counts all comments, excluding deleted favorites.
1✔
591
        """
592
        return comment.Comment.where_count("sharedfile_id = %s and deleted = 0", self.id)
1✔
593

594
    def comments(self):
595
        """
596
        Select comments for a sharedfile.
597
        """
1✔
598
        return comment.Comment.where('sharedfile_id=%s and deleted = 0', self.id)
599

1✔
600
    def feed_date(self):
601
        """
602
        Returns a date formatted to be included in feeds
603
        e.g., Tue, 12 Apr 2005 13:59:56 EST
1✔
604
        """
1✔
605
        return rfc822_date(self.created_at)
1✔
606

×
607
    def thumbnail_url(self, direct=False):
×
608
        # If we are running on Fastly, then we can use the Image Optimizer to
×
609
        # resize a given image. Thumbnail size is 100x100. This size is used
610
        # for the conversations page.
×
611
        sourcefile = self.sourcefile()
612
        size = 0
1✔
613
        if sourcefile.type == 'image':
×
614
            if self.original_id > 0:
615
                original = self.original()
1✔
616
                if original:
617
                    size = original.size
618
            else:
1✔
619
                size = self.size
620
        # Fastly I/O won't process images > 50mb, so condition for that
621
        if sourcefile.type == 'image' and options.use_fastly and size > 0 and size < 50_000_000:
622
            if direct:
×
623
                return f"https://{options.cdn_host}/s3/originals/{sourcefile.file_key}?width=100"
×
624
            else:
×
625
                return f"https://{options.cdn_host}/r/{self.share_key}?width=100"
×
626
        else:
×
627
            return s3_url(options.aws_key, options.aws_secret, options.aws_bucket, \
×
628
                file_path="thumbnails/%s" % (sourcefile.thumb_key), seconds=3600)
629

×
630
    def small_thumbnail_url(self, direct=False):
631
        # If we are running on Fastly, then we can use the Image Optimizer to
×
632
        # resize a given image. Small thumbnails are 270-wide at most. This size is
×
633
        # currently only used within the admin UI.
634
        sourcefile = self.sourcefile()
×
635
        size = 0
636
        if sourcefile.type == 'image':
637
            if self.original_id > 0:
1✔
638
                original = self.original()
1✔
639
                if original: size = original.size
1✔
640
            else:
641
                size = self.size
1✔
642
        # Fastly I/O won't process images > 50mb, so condition for that
643
        if sourcefile.type == 'image' and options.use_fastly and size > 0 and size < 50_000_000:
644
            if direct:
645
                return f"https://{options.cdn_host}/s3/originals/{sourcefile.file_key}?width=270"
646
            else:
1✔
647
                return f"https://{options.cdn_host}/r/{self.share_key}?width=270"
1✔
648
        else:
649
            return s3_url(options.aws_key, options.aws_secret, options.aws_bucket, \
1✔
650
                file_path="smalls/%s" % (sourcefile.small_key), seconds=3600)
1✔
651

1✔
652
    def type(self):
653
        source = sourcefile.Sourcefile.get("id = %s", self.source_id)
1✔
654
        return source.type
1✔
655

1✔
656
    def set_nsfw(self, set_by_user):
1✔
657
        """
1✔
658
        Process a request to set the nsfw flag on the sourcefile.  Also logs the
1✔
659
        the user, sharedfile and sourcefile in the NSFWLog table.
660
        """
1✔
661
        sourcefile = self.sourcefile()
662
        log_entry = models.nsfw_log.NSFWLog(user_id=set_by_user.id, sharedfile_id=self.id,
1✔
663
                                            sourcefile_id=sourcefile.id)
664
        log_entry.save()
1✔
665
        if sourcefile.nsfw == 0:
1✔
666
            sourcefile.update_attribute('nsfw', 1)
667

668
    def find_tags(self):
669
        if not self.description:
670
            return []
671
        candidates = set(part[1:] for part in self.description.split() if part.startswith('#'))
672
        candidates = [re.search(r'[a-zA-Z0-9]+', c).group(0) for c in candidates]
673
        return set([c.lower() for c in candidates if len(c) < 21])
674

1✔
675
    def tags(self):
1✔
676
        #return models.TaggedFile.where("sharedfile_id = %s and deleted = 0", self.id)
1✔
677
        return [models.Tag.get('id = %s', tf.tag_id) for tf in models.TaggedFile.where("sharedfile_id = %s and deleted = 0", self.id)]
1✔
678

1✔
679
    @classmethod
1✔
680
    def from_subscriptions(self, user_id, per_page=10, before_id=None, after_id=None, q=None):
1✔
681
        """
682
        Pulls the user's timeline, can key off and go backwards (before_id) and forwards (after_id)
1✔
683
        in time to pull the per_page amount of posts.  Always returns the files in reverse
1✔
684
        chronological order.
×
685

×
686
        We split out the join from the query and only pull the sharedfile_id because MySQL's
687
        query optimizer does not use the index consistently. -- IK
688
        """
689
        constraint_sql = ""
690
        order = "desc"
691
        if before_id:
1✔
692
            constraint_sql = "AND post.sharedfile_id < %s" % (int(before_id))
693
        elif after_id:
694
            order = "asc"
695
            constraint_sql = "AND post.sharedfile_id > %s" % (int(after_id))
696

697
        select_args = []
698
        if q is not None:
699
            constraint_sql += " AND MATCH (sharedfile.title, sharedfile.description, sharedfile.alt_text) AGAINST (%s IN BOOLEAN MODE)"
1✔
700
            select_args.append(q)
1✔
701

1✔
702
        # We aren't joining on sharedfile using the deleted column since that
1✔
703
        # causes the query to run more slowly, particulary when looking for
1✔
704
        # ranges when paginating. We apply a delete filter for the post records,
1✔
705
        # which should suffice (post records are deleted when an image is deleted).
1✔
706
        select = """SELECT sharedfile_id, shake_id FROM post
1✔
707
                    JOIN sharedfile on sharedfile.id = sharedfile_id
1✔
708
                    WHERE post.user_id = %s
709
                    AND post.seen = 0
710
                    AND post.deleted = 0
1✔
711
                    %s
1✔
712
                    ORDER BY post.sharedfile_id %s limit %s, %s""" % (int(user_id), constraint_sql, order, 0, per_page)
713

714
        posts = self.query(select, *select_args)
715
        results = []
×
716
        for post in posts:
×
717
            sf = Sharedfile.get('id=%s', post['sharedfile_id'])
718
            sf.shake_id = post['shake_id']
719
            results.append(sf)
720
        if order == "asc":
721
            results.reverse()
722
        return results
723

×
724

725
    @classmethod
726
    def subscription_time_line(self, user_id, page=1, per_page=10):
1✔
727
        """
1✔
728
        DEPRACATED: We no longer paginate like this. instead we use Sharedfile.from_subscription
729
        """
730
        limit_start = (page-1) * per_page
731
        select = """SELECT sharedfile.* FROM sharedfile, post
1✔
732
                  WHERE post.user_id = %s
1✔
733
                  AND post.sharedfile_id = sharedfile.id
734
                  AND post.seen = 0
1✔
735
                  AND post.deleted = 0
1✔
736
                  AND sharedfile.deleted = 0
1✔
737
                  ORDER BY post.created_at desc limit %s, %s""" % (int(user_id), int(limit_start), per_page)
1✔
738
        return self.object_query(select)
1✔
739

740

1✔
741
    @classmethod
1✔
742
    def favorites_for_user(self, user_id, before_id=None, after_id=None, per_page=10, q=None):
×
743
        """
×
744
        A user likes (i.e. Favorite).
745
        """
746
        constraint_sql = ""
747
        order = "desc"
748

1✔
749
        if before_id:
750
            constraint_sql = "AND favorite.id < %s" % (int(before_id))
751
        elif after_id:
752
            order = "asc"
753
            constraint_sql = "AND favorite.id > %s" % (int(after_id))
754

755
        select_args = []
1✔
756
        if q is not None:
1✔
757
            constraint_sql += " AND MATCH (sharedfile.title, sharedfile.description, sharedfile.alt_text) AGAINST (%s IN BOOLEAN MODE)"
1✔
758
            select_args.append(q)
1✔
759

760
        # The `GROUP BY source_id`` constrains the result so it is only returning a single row
761
        # per source_id. MySQL server cannot have ONLY_FULL_GROUP_BY present in sql_mode
1✔
762
        # to allow this query.
1✔
763
        select = """SELECT sharedfile.*, favorite.id as favorite_id
764
                FROM favorite
765
                JOIN sharedfile on sharedfile.id = favorite.sharedfile_id and sharedfile.deleted = 0
766
            WHERE favorite.user_id = %s and favorite.deleted = 0
1✔
767
            %s
1✔
768
            GROUP BY sharedfile.source_id
769
            ORDER BY favorite.id %s limit 0, %s"""  % (int(user_id), constraint_sql, order, per_page)
1✔
770
        files = self.object_query(select, *select_args)
1✔
771
        if order == "asc":
772
            files.reverse()
773
        return files
774

775

1✔
776
    @classmethod
1✔
777
    def get_by_share_key(self, share_key):
778
        """
1✔
779
        Returns a Sharedfile by its share_key. Deleted files don't get returned.
1✔
780
        """
1✔
781
        sharedfile_id = base36decode(share_key)
1✔
782
        return self.get("id = %s and deleted = 0", sharedfile_id)
1✔
783

784
    @classmethod
1✔
785
    def incoming(self, before_id=None, after_id=None, per_page=10):
786
        """
787
        Fetches the per_page amount of incoming files.  Filters out any files where
788
        the user is marked as nsfw.
789
        """
790
        constraint_sql = ""
791
        order = "desc"
792

1✔
793
        if before_id:
1✔
794
            constraint_sql = "AND sharedfile.id < %s" % (int(before_id))
1✔
795
        elif after_id:
1✔
796
            order = "asc"
797
            constraint_sql = "AND sharedfile.id > %s" % (int(after_id))
798

1✔
799
        select = """SELECT sharedfile.* FROM sharedfile, user
1✔
800
                    WHERE sharedfile.deleted = 0
×
801
                    AND sharedfile.parent_id = 0
×
802
                    AND sharedfile.original_id = 0
×
803
                    AND sharedfile.user_id = user.id
×
804
                    AND user.nsfw = 0
×
805
                    %s
×
806
                    ORDER BY id %s LIMIT %s""" % (constraint_sql, order, per_page)
×
807
        files = self.object_query(select)
×
808
        if order == "asc":
×
809
            files.reverse()
810
        return files
1✔
811

1✔
812

813
    @staticmethod
814
    def get_sha1_file_key(file_path):
815
        try:
1✔
816
            fh = open(file_path, 'r')
×
817
            file_data = fh.read()
818
            fh.close()
1✔
819
            h = hashlib.sha1()
×
820
            h.update(file_data)
821
            return h.hexdigest()
1✔
822
        except Exception as e:
×
823
            return None
824

825
    @staticmethod
826
    def create_from_file(file_path, file_name, sha1_value, content_type, user_id, title=None, shake_id=None, skip_s3=None):
1✔
827
        """
1✔
828
        TODO: Must only accept acceptable content-types after consulting a list.
829
        """
1✔
830
        if len(sha1_value) != 40:
1✔
831
            return None
×
832

1✔
833
        if user_id == None:
1✔
834
            return None
835

1✔
836
        if content_type not in ['image/gif', 'image/jpeg', 'image/jpg', 'image/png']:
837
            return None
1✔
838

1✔
839
        # If we have no shake_id, drop in user's main shake. Otherwise, validate that the specififed
1✔
840
        # shake is a group shake that the user has permissions for.
1✔
841
        if not shake_id:
1✔
842
            destination_shake = shake.Shake.get('user_id = %s and type=%s and deleted=0', user_id, 'user')
1✔
843
        else:
1✔
844
            destination_shake = shake.Shake.get('id=%s and deleted=0', shake_id)
845
            if not destination_shake:
1✔
846
                return None
×
847
            if not destination_shake.can_update(user_id):
1✔
848
                return None
849

×
850
        sf = sourcefile.Sourcefile.get_from_file(file_path, sha1_value, skip_s3=skip_s3, content_type=content_type)
851

×
852
        if sf:
853
            shared_file = Sharedfile(user_id = user_id, name=file_name, content_type=content_type, source_id=sf.id, title=title, size=path.getsize(file_path))
854
            shared_file.save()
855
            if shared_file.saved():
856
                shared_file.share_key = base36encode(shared_file.id)
857
                shared_file.save()
858
                shared_file.add_to_shake(destination_shake)
859

860
                if options.use_workers and content_type == "image/gif":
861
                    transcode_sharedfile.delay_or_run(shared_file.id)
862
                return shared_file
863
            else:
864
                return None
865
        else:
866
            return None
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