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

MLTSHP / mltshp / 0194ca4d-c90a-4014-b7af-7f9b66ec3087

03 Feb 2025 05:42AM UTC coverage: 69.534% (+0.02%) from 69.51%
0194ca4d-c90a-4014-b7af-7f9b66ec3087

push

buildkite

4615 of 6637 relevant lines covered (69.53%)

0.7 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

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

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

1✔
92
    def get_alt_text(self, raw=False):
93
        """
1✔
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
98
        if alt_text is None:
1✔
99
            alt_text = ''
1✔
100

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

105
        return alt_text.strip()
1✔
106

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

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

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']:
1✔
120
            ignore_tags = True
1✔
121

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

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

127
        if ignore_tags:
1✔
128
            return
1✔
129

130
        # clear out all tags
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()
1✔
135

136
        # extract tags
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()
1✔
143

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

154

155
    def can_save(self, user_check=None):
1✔
156
        """
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:
1✔
162
            return False
×
163
        if not user_check:
1✔
164
            return False
1✔
165
        if self.user_id == user_check.id:
1✔
166
            return False
1✔
167
        else:
168
            return True
1✔
169

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

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

196
    def can_unfavor(self, user_check=None):
1✔
197
        """
198
        Any use can favorite if they've already favored.
199
        """
200
        if options.readonly:
1✔
201
            return False
×
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)
1✔
207

208
    def can_edit(self, user_check=None):
1✔
209
        """
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:
1✔
214
            return False
×
215
        if not user_check:
1✔
216
            return False
1✔
217
        if self.user_id == user_check.id:
1✔
218
            return True
1✔
219
        else:
220
            return False
1✔
221

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

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

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

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

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

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

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

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

272
        extra_attributes = ""
1✔
273

274
        # Allows iframe to trigger full-screen display
275
        if 'allowfullscreen' not in html:
1✔
276
            extra_attributes += " allowfullscreen"
1✔
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
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"'
1✔
285

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

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

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

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

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

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

306
        return html
1✔
307

308
    def post_url(self, relative=False):
1✔
309
        if relative:
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)
1✔
314

1✔
315
    def as_json(self, user_context=None):
316
        """
1✔
317
        If user_context is provided, adds a couple of fields to
1✔
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,
336
            'title' : self.title,
1✔
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',
340
            'permalink_page' : self.post_url(),
1✔
341
        }
×
342

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

1✔
347
        if(source.type == 'link'):
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)
1✔
351
        return json_object
352

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

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

×
372
    def delete_from_shake(self, from_shake):
1✔
373
        """
1✔
374
        Removes a file from a shake.  Make sure we find the shakesharedfile entry and only mark it as
×
375
        deleted if it's in another shake (2 or more shakes when this action was initiated).
1✔
376
        """
1✔
377
        if options.readonly:
1✔
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:
381
            return False
1✔
382
        ssf.deleted = 1
383
        if ssf.save():
384
            return True
385
        else:
386
            return False
1✔
387

×
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
1✔
395
        ssf = shakesharedfile.Shakesharedfile.get("shake_id = %s and sharedfile_id = %s and deleted=0", to_shake.id, self.id)
396
        if not ssf:
1✔
397
            ssf = shakesharedfile.Shakesharedfile(shake_id=to_shake.id, sharedfile_id=self.id)
398
        ssf.deleted = 0
399
        ssf.save()
400
        if ssf.saved():
1✔
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 = """
408
            select shake.* from shake
1✔
409
            left join shakesharedfile on
410
            shakesharedfile.shake_id = shake.id
1✔
411
            where shake.deleted = 0
412
            and shakesharedfile.sharedfile_id = %s
413
            and shakesharedfile.deleted = 0;
414
        """
1✔
415
        return shake.Shake.object_query(select, self.id)
416

1✔
417
    def user(self):
418
        """
419
        Returns sharedfile's user.
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):
1✔
424
        """
425
        Returns the parent object if it's set, otherwise returns None.
1✔
426
        """
1✔
427
        if not bool(self.parent_id):
428
            return None
1✔
429
        if include_deleted:
430
            deleted_clause = ''
431
        else:
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):
1✔
436
        """
437
        Returns the original object if it's set, otherwise returns None.
1✔
438
        """
1✔
439
        if not bool(self.original_id):
440
            return None
1✔
441
        if include_deleted:
442
            deleted_clause = ''
443
        else:
444
            deleted_clause = ' and deleted=0'
445
        return self.get("id = %s" + deleted_clause, self.original_id)
1✔
446

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

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

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

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

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()
1✔
481

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

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

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

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

×
496

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

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

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

1✔
514
    def _set_dates(self):
1✔
515
        """
516
        Sets the created_at and updated_at fields. This should be something
1✔
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:
520
            self.created_at = utcnow()
1✔
521
        self.updated_at = utcnow()
1✔
522

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

530
    def calculate_view_count(self, last_fileview=0):
1✔
531
        """
532
        Calculate count of all views for the sharedfile.
1✔
533
        """
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)
1✔
537
        return int(count[0]['result_count'])
1✔
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:
546
            # if a file is not recent and also has zero
1✔
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.
556
        """
1✔
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)
562

1✔
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.
575
        """
1✔
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)
581

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

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

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

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

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

1✔
652
    def type(self):
1✔
653
        source = sourcefile.Sourcefile.get("id = %s", self.source_id)
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.
1✔
660
        """
661
        sourcefile = self.sourcefile()
1✔
662
        log_entry = models.nsfw_log.NSFWLog(user_id=set_by_user.id, sharedfile_id=self.id,
663
                                            sourcefile_id=sourcefile.id)
1✔
664
        log_entry.save()
665
        if sourcefile.nsfw == 0:
1✔
666
            sourcefile.update_attribute('nsfw', 1)
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

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
        """
1✔
682
        Pulls the user's timeline, can key off and go backwards (before_id) and forwards (after_id)
683
        in time to pull the per_page amount of posts.  Always returns the files in reverse
1✔
684
        chronological order.
1✔
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:
692
            constraint_sql = "AND post.sharedfile_id < %s" % (int(before_id))
1✔
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)"
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
1✔
709
                    AND post.seen = 0
710
                    AND post.deleted = 0
711
                    %s
1✔
712
                    ORDER BY post.sharedfile_id %s limit %s, %s""" % (int(user_id), constraint_sql, order, 0, per_page)
1✔
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):
727
        """
1✔
728
        DEPRACATED: We no longer paginate like this. instead we use Sharedfile.from_subscription
1✔
729
        """
730
        limit_start = (page-1) * per_page
731
        select = """SELECT sharedfile.* FROM sharedfile, post
732
                  WHERE post.user_id = %s
1✔
733
                  AND post.sharedfile_id = sharedfile.id
1✔
734
                  AND post.seen = 0
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

1✔
740

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

749
        if before_id:
1✔
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 = []
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

1✔
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
762
        # to allow this query.
1✔
763
        select = """SELECT sharedfile.*, favorite.id as favorite_id
1✔
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
767
            %s
1✔
768
            GROUP BY sharedfile.source_id
1✔
769
            ORDER BY favorite.id %s limit 0, %s"""  % (int(user_id), constraint_sql, order, per_page)
770
        files = self.object_query(select, *select_args)
1✔
771
        if order == "asc":
1✔
772
            files.reverse()
773
        return files
774

775

776
    @classmethod
1✔
777
    def get_by_share_key(self, share_key):
1✔
778
        """
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

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

793
        if before_id:
1✔
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))
798

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

1✔
812

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

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

836
        if content_type not in ['image/gif', 'image/jpeg', 'image/jpg', 'image/png']:
1✔
837
            return None
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)
1✔
845
            if not destination_shake:
846
                return None
1✔
847
            if not destination_shake.can_update(user_id):
×
848
                return None
1✔
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