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

MLTSHP / mltshp / 0194caa7-646a-4342-9732-0b1c903800fd

03 Feb 2025 07:24AM UTC coverage: 69.539% (+0.005%) from 69.534%
0194caa7-646a-4342-9732-0b1c903800fd

push

buildkite

4616 of 6638 relevant lines covered (69.54%)

0.7 hits per line

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

84.98
/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

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

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
1✔
32

33

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)
1✔
45
    # we set default to 0, since DB does not accept Null values
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()
1✔
55

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

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

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

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

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

1✔
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>')
90
        return description.strip()
91

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

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

1✔
105
        return alt_text.strip()
106

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

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

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

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

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

1✔
127
        if ignore_tags:
128
            return
1✔
129

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

1✔
136
        # extract tags
137
        tags = self.find_tags()
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()
1✔
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:
147
                tagged_file.deleted = 0
1✔
148
                tagged_file.save()
1✔
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

1✔
154

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

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

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

1✔
183
    def can_favor(self, user_check=None):
184
        """
1✔
185
        Can favor any image a user hasn't favorited, except
186
        if it's your image.
187
        """
188
        if options.readonly:
189
            return False
1✔
190
        if not user_check:
×
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)
1✔
195

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

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

1✔
222
    def save_to_shake(self, for_user, to_shake=None):
223
        """
1✔
224
        Saves this file to a user's shake, or to the to_shake
225
        if it is provided.
226
        """
227
        new_sharedfile = Sharedfile()
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
1✔
237

1✔
238
        if self.original_id == 0:
239
            new_sharedfile.original_id = self.id
1✔
240
        else:
1✔
241
            new_sharedfile.original_id = self.original_id
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)
1✔
245

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

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

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

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

×
267
        html = oembed['html'] or ""
268

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

1✔
272
        extra_attributes = ""
273

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

1✔
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
283
        if 'sandbox=' not in html:
284
            extra_attributes += ' sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-presentation"'
1✔
285

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

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

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

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

1✔
301
        if extra_attributes:
302
            html = html.replace('<iframe ', '<iframe ' + extra_attributes)
1✔
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:
1✔
310
            return '/p/%s' % (self.share_key)
311
        else:
312
            scheme = (options.use_cdn and "https") or "http"
313
            return '%s://%s/p/%s' % (scheme, options.app_host, self.share_key)
314

1✔
315
    def as_json(self, user_context=None):
1✔
316
        """
317
        If user_context is provided, adds a couple of fields to
1✔
318
        the returned dict representation, such as 'saved' and 'liked'.
1✔
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,
336
            'title' : self.title,
337
            'description' : self.description,
1✔
338
            'alt_text' : self.alt_text,
1✔
339
            'posted_at' : self.created_at.replace(microsecond=0, tzinfo=None).isoformat() + 'Z',
1✔
340
            'permalink_page' : self.post_url(),
341
        }
1✔
342

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

347
        if(source.type == 'link'):
1✔
348
            json_object['url'] = self.source_url
349
        else:
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):
1✔
354
        """
355
        Returns sharedfile's Sourcefile.
356
        """
357
        return sourcefile.Sourcefile.get("id = %s", self.source_id)
358

1✔
359
    def can_user_delete_from_shake(self, user, from_shake):
×
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
        """
1✔
364
        if options.readonly:
1✔
365
            return False
366
        if self.user_id == user.id:
1✔
367
            return True
368
        if from_shake.is_owner(user):
369
            return True
370
        return False
371

1✔
372
    def delete_from_shake(self, from_shake):
×
373
        """
1✔
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).
×
376
        """
1✔
377
        if options.readonly:
1✔
378
            return False
1✔
379
        ssf = shakesharedfile.Shakesharedfile.get("shake_id = %s and sharedfile_id = %s and deleted=0", from_shake.id, self.id)
380
        if not ssf:
×
381
            return False
382
        ssf.deleted = 1
1✔
383
        if ssf.save():
384
            return True
385
        else:
386
            return False
387

1✔
388
    def add_to_shake(self, to_shake):
×
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
1✔
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)
1✔
398
        ssf.deleted = 0
399
        ssf.save()
400
        if ssf.saved():
401
            add_posts.delay_or_run(shake_id=to_shake.id, sharedfile_id=self.id, sourcefile_id=self.source_id)
1✔
402

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

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

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

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

1✔
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
        """
1✔
452
        parent = self.parent()
453
        if not parent:
454
            return None
455
        return parent.user()
456

1✔
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
        """
1✔
462
        original = self.original()
463
        if not original:
464
            return None
465
        return original.user()
1✔
466

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

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

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

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

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

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

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

1✔
496

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

504
        if not user_id:
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):
1✔
509
        """
510
        A friendly version of the created_at date.
511
        """
512
        return pretty_date(self.created_at)
513

1✔
514
    def _set_dates(self):
1✔
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.
1✔
518
        """
519
        if self.id is None or self.created_at is None:
520
            self.created_at = utcnow()
521
        self.updated_at = utcnow()
1✔
522

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

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

1✔
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:
×
546
            # if a file is not recent and also has zero
547
            # then try to pull a live count anyway.
1✔
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.
×
556
        """
557
        original =  self.where("original_id = %s and deleted = 0", self.id)
1✔
558
        if len(original) > 0:
559
            return original
560
        else:
561
            return self.where("parent_id = %s and deleted = 0", self.id)
×
562

563
    def favorites(self):
1✔
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.
×
575
        """
576
        original =  self.where_count("original_id = %s and deleted = 0", self.id)
1✔
577
        if original > 0:
578
            return original
579
        else:
580
            return self.where_count("parent_id = %s and deleted = 0", self.id)
×
581

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

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

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

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

1✔
607
    def thumbnail_url(self, direct=False):
1✔
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
×
613
        if sourcefile.type == 'image':
614
            if self.original_id > 0:
1✔
615
                original = self.original()
×
616
                if original:
617
                    size = original.size
1✔
618
            else:
619
                size = self.size
620
        # Fastly I/O won't process images > 50mb, so condition for that
1✔
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:
638
                original = self.original()
639
                if original: size = original.size
1✔
640
            else:
1✔
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:
1✔
644
            if direct:
645
                return f"https://{options.cdn_host}/s3/originals/{sourcefile.file_key}?width=270"
646
            else:
647
                return f"https://{options.cdn_host}/r/{self.share_key}?width=270"
648
        else:
1✔
649
            return s3_url(options.aws_key, options.aws_secret, options.aws_bucket, \
1✔
650
                file_path="smalls/%s" % (sourcefile.small_key), seconds=3600)
651

1✔
652
    def type(self):
1✔
653
        source = sourcefile.Sourcefile.get("id = %s", self.source_id)
1✔
654
        return source.type
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.
1✔
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:
666
            sourcefile.update_attribute('nsfw', 1)
1✔
667

1✔
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

675
    def tags(self):
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
        """
1✔
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
684
        chronological order.
1✔
685

1✔
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:
692
            constraint_sql = "AND post.sharedfile_id < %s" % (int(before_id))
693
        elif after_id:
1✔
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)"
700
            select_args.append(q)
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
1✔
709
                    AND post.seen = 0
1✔
710
                    AND post.deleted = 0
711
                    %s
712
                    ORDER BY post.sharedfile_id %s limit %s, %s""" % (int(user_id), constraint_sql, order, 0, per_page)
1✔
713

1✔
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):
727
        """
728
        DEPRACATED: We no longer paginate like this. instead we use Sharedfile.from_subscription
1✔
729
        """
1✔
730
        limit_start = (page-1) * per_page
731
        select = """SELECT sharedfile.* FROM sharedfile, post
732
                  WHERE post.user_id = %s
733
                  AND post.sharedfile_id = sharedfile.id
1✔
734
                  AND post.seen = 0
1✔
735
                  AND post.deleted = 0
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

1✔
740

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

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

755
        select_args = []
756
        if q is not None:
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

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

775

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

1✔
784
    @classmethod
1✔
785
    def incoming(self, before_id=None, after_id=None, per_page=10):
786
        """
1✔
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

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

799
        select = """SELECT sharedfile.* FROM sharedfile, user
800
                    WHERE sharedfile.deleted = 0
1✔
801
                    AND sharedfile.parent_id = 0
1✔
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
×
811

812

1✔
813
    @staticmethod
1✔
814
    def get_sha1_file_key(file_path):
815
        try:
816
            fh = open(file_path, 'r')
817
            file_data = fh.read()
1✔
818
            fh.close()
×
819
            h = hashlib.sha1()
820
            h.update(file_data)
1✔
821
            return h.hexdigest()
×
822
        except Exception as e:
823
            return None
1✔
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):
827
        """
828
        TODO: Must only accept acceptable content-types after consulting a list.
1✔
829
        """
1✔
830
        if len(sha1_value) != 40:
831
            return None
1✔
832

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

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

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)
1✔
845
            if not destination_shake:
1✔
846
                return None
847
            if not destination_shake.can_update(user_id):
1✔
848
                return None
×
849

1✔
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