diff --git a/.dockerignore b/.dockerignore index 9e95e1ed..046f64bf 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,6 +10,8 @@ celeryconfig.py .eggs/ build/ html/ +web/ +worker/ pip-log.txt .DS_Store *.swp diff --git a/Makefile b/Makefile index f63e59c9..10505da4 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: init start stop custom-build build shell test destroy migrate mysql +.PHONY: init start stop custom-build build staging shell test destroy migrate mysql init: cp settings.example.py settings.py @@ -18,6 +18,9 @@ custom-build: build: docker build -t mltshp/mltshp-web:latest . +staging: + docker build --platform linux/amd64 -t mltshp/mltshp-web:staging . + shell: docker compose exec mltshp bash diff --git a/celeryconfig.example.py b/celeryconfig.example.py index dbdd894c..be89855c 100644 --- a/celeryconfig.example.py +++ b/celeryconfig.example.py @@ -7,7 +7,7 @@ ## Celery configuration # List of modules to import when celery starts. -imports = ("tasks.timeline", "tasks.counts", "tasks.migration", "tasks.transcode") +imports = ("tasks.timeline", "tasks.counts", "tasks.migration", "tasks.transcode", "tasks.admin") task_routes = { "tasks.transcode.*": { "queue": "transcode" }, diff --git a/handlers/admin.py b/handlers/admin.py index 7ac011ab..c3ade0cc 100644 --- a/handlers/admin.py +++ b/handlers/admin.py @@ -6,8 +6,9 @@ from .base import BaseHandler from models import Sharedfile, User, Shake, Invitation, Waitlist, ShakeCategory, \ - DmcaTakedown, Comment -from lib.utilities import send_slack_notification + DmcaTakedown, Comment, Favorite, PaymentLog, Conversation +from lib.utilities import send_slack_notification, pretty_date +from tasks.admin import delete_account class AdminBaseHandler(BaseHandler): @@ -52,6 +53,62 @@ def get(self): return self.render("admin/index.html") +class UserHandler(AdminBaseHandler): + @tornado.web.authenticated + def get(self, user_name): + plan_names = { + "mltshp-single-canceled": "Single Scoop - Canceled", + "mltshp-single": "Single Scoop", + "mltshp-double-canceled": "Double Scoop - Canceled", + "mltshp-double": "Double Scoop", + } + user = User.get('name=%s', user_name) + if not user: + return self.redirect('/admin?error=User%20not%20found') + + post_count = "{:,d}".format(Sharedfile.where_count("user_id=%s and deleted=0", user.id)) + shake_count = "{:,d}".format(Shake.where_count("user_id=%s and deleted=0", user.id)) + comment_count = "{:,d}".format(Comment.where_count("user_id=%s and deleted=0", user.id)) + like_count = "{:,d}".format(Favorite.where_count("user_id=%s and deleted=0", user.id)) + last_activity_date = user.get_last_activity_date() + pretty_last_activity_date = last_activity_date and pretty_date(last_activity_date) or "None" + subscribed = bool(user.is_paid) + subscription = subscribed and user.active_paid_subscription() + subscription_level = plan_names.get(user.stripe_plan_id) or None + subscription_start = subscription and subscription['start_date'] + subscription_end = subscription and subscription['end_date'] + all_payments = PaymentLog.where("user_id=%s", user.id) + total_payments = 0.00 + for payment in all_payments: + if payment.status == "payment": + total_payments = total_payments + float(payment.transaction_amount.split(" ")[1]) + uploaded_all_time_mb = "{:,.2f}".format(user.uploaded_kilobytes() / 1024) + uploaded_this_month_mb = "{:,.2f}".format(user.uploaded_this_month() / 1024) + # select all _original_ posts from this user; we care less about reposts for this view + recent_posts = Sharedfile.where("user_id=%s and original_id=0 order by created_at desc limit 50", user.id) + recent_comments = Comment.where("user_id=%s order by created_at desc limit 100", user.id) + + return self.render( + "admin/user.html", + user=user, + user_name=user_name, + post_count=post_count, + shake_count=shake_count, + comment_count=comment_count, + like_count=like_count, + uploaded_all_time_mb=uploaded_all_time_mb, + uploaded_this_month_mb=uploaded_this_month_mb, + total_payments=total_payments, + subscribed=subscribed, + subscription_level=subscription_level, + subscription_start=subscription_start, + subscription_end=subscription_end, + last_activity_date=last_activity_date, + pretty_last_activity_date=pretty_last_activity_date, + recent_posts=recent_posts, + recent_comments=recent_comments,) + + class NSFWUserHandler(AdminBaseHandler): @tornado.web.authenticated def get(self): @@ -80,11 +137,17 @@ def get(self): if not self.admin_user.is_superuser(): return self.redirect('/admin') + share_key = self.get_argument("share_key", None) + if share_key: + sharedfile = Sharedfile.get("share_key=%s AND deleted=0", share_key) + else: + sharedfile = None + return self.render( "admin/image-takedown.html", - share_key="", + share_key=share_key or "", confirm_step=False, - sharedfile=None, + sharedfile=sharedfile, comment="", canceled=self.get_argument('canceled', "0") == "1", deleted=self.get_argument('deleted', "0") == "1") @@ -168,19 +231,30 @@ def post(self): class DeleteUserHandler(AdminBaseHandler): - @tornado.web.authenticated - def get(self): - if not self.admin_user.is_superuser(): - return self.redirect('/admin') - return self.render('admin/delete-user.html') - @tornado.web.authenticated def post(self): - user_id = self.get_argument('user_id') + # Only a superuser can delete users + if not self.admin_user.is_superuser(): + return self.write({'error': 'not allowed'}) + user_name = self.get_argument('user_name') - user = User.get('name=%s and id=%s', user_name, user_id) - user.delete() - return self.redirect('/user/%s' % user_name) + user = None + if user_name: + user = User.get('name=%s', user_name) + + if user: + # admin users cannot be deleted (moderator or superuser) + if user.is_admin(): + return self.write({'error': 'cannot delete admin'}) + + # Flag as deleted; send full deletion work to the background + user.deleted = 1 + user.save() + + delete_account.delay_or_run(user_id=user.id) + return self.write({'response': 'ok' }) + else: + return self.write({'error': 'user not found'}) class FlagNSFWHandler(AdminBaseHandler): @@ -190,6 +264,7 @@ def post(self, user_name): if not user: return self.redirect('/') + json = int(self.get_argument("json", 0)) nsfw = int(self.get_argument("nsfw", 0)) if nsfw == 1: user.flag_nsfw() @@ -198,7 +273,10 @@ def post(self, user_name): user.save() send_slack_notification("%s flagged user '%s' as %s" % (self.admin_user.name, user.name, nsfw == 1 and "NSFW" or "SFW"), channel="#moderation", icon_emoji=":ghost:", username="modbot") - return self.redirect("/user/%s" % user.name) + if json == 1: + return self.write({'response': 'ok' }) + else: + return self.redirect("/user/%s" % user.name) class RecommendedGroupShakeHandler(AdminBaseHandler): diff --git a/handlers/base.py b/handlers/base.py index 806349b1..f3b51b6f 100644 --- a/handlers/base.py +++ b/handlers/base.py @@ -81,6 +81,7 @@ def render_string(self, template_name, **kwargs): kwargs['current_user_object'] = current_user_object kwargs['site_is_readonly'] = options.readonly == 1 kwargs['disable_signups'] = options.disable_signups == 1 + kwargs['xsrf_token'] = self.xsrf_token # site merchandise promotions are shown to members kwargs['show_promos'] = options.show_promos and ( current_user_object and current_user_object.is_paid == 1) diff --git a/lib/uimodules.py b/lib/uimodules.py index 5ee4cebc..7b3ffbab 100644 --- a/lib/uimodules.py +++ b/lib/uimodules.py @@ -163,10 +163,10 @@ def render(self, sharedfile, current_user=None, list_view=False, show_attributio class ImageMedium(UIModule): - def render(self, sharedfile): + def render(self, sharedfile, direct=False): sharedfile_user = sharedfile.user() return self.render_string("uimodules/image-medium.html", sharedfile=sharedfile, \ - sharedfile_user=sharedfile_user) + sharedfile_user=sharedfile_user, direct=direct) class ShakeFollow(UIModule): def render(self, follow_user=None, follow_shake=None, current_user=None, diff --git a/models/sharedfile.py b/models/sharedfile.py index 3f387459..9001db89 100644 --- a/models/sharedfile.py +++ b/models/sharedfile.py @@ -604,7 +604,7 @@ def feed_date(self): """ return rfc822_date(self.created_at) - def thumbnail_url(self): + def thumbnail_url(self, direct=False): # If we are running on Fastly, then we can use the Image Optimizer to # resize a given image. Thumbnail size is 100x100. This size is used # for the conversations page. @@ -619,14 +619,17 @@ def thumbnail_url(self): size = self.size # Fastly I/O won't process images > 50mb, so condition for that if sourcefile.type == 'image' and options.use_fastly and size > 0 and size < 50_000_000: - return f"https://{options.cdn_host}/r/{self.share_key}?width=100" + if direct: + return f"https://{options.cdn_host}/s3/originals/{sourcefile.file_key}?width=100" + else: + return f"https://{options.cdn_host}/r/{self.share_key}?width=100" else: return s3_url(options.aws_key, options.aws_secret, options.aws_bucket, \ file_path="thumbnails/%s" % (sourcefile.thumb_key), seconds=3600) - def small_thumbnail_url(self): + def small_thumbnail_url(self, direct=False): # If we are running on Fastly, then we can use the Image Optimizer to - # resize a given image. Small thumbnails are 240x184 at most. This size is + # resize a given image. Small thumbnails are 270-wide at most. This size is # currently only used within the admin UI. sourcefile = self.sourcefile() size = 0 @@ -638,7 +641,10 @@ def small_thumbnail_url(self): size = self.size # Fastly I/O won't process images > 50mb, so condition for that if sourcefile.type == 'image' and options.use_fastly and size > 0 and size < 50_000_000: - return f"https://{options.cdn_host}/r/{self.share_key}?width=240&height=184&fit=bounds" + if direct: + return f"https://{options.cdn_host}/s3/originals/{sourcefile.file_key}?width=270" + else: + return f"https://{options.cdn_host}/r/{self.share_key}?width=270" else: return s3_url(options.aws_key, options.aws_secret, options.aws_bucket, \ file_path="smalls/%s" % (sourcefile.small_key), seconds=3600) diff --git a/models/sourcefile.py b/models/sourcefile.py index 74590ba5..aaa91b9f 100644 --- a/models/sourcefile.py +++ b/models/sourcefile.py @@ -136,7 +136,7 @@ def get_from_file(file_path, sha1_value, type='image', skip_s3=None, content_typ small = img.copy() thumb.thumbnail((100,100), Image.Resampling.LANCZOS) - small.thumbnail((240,184), Image.Resampling.LANCZOS) + small.thumbnail((270,200), Image.Resampling.LANCZOS) thumb.save(thumb_cstr, format="JPEG") small.save(small_cstr, format="JPEG") diff --git a/models/user.py b/models/user.py index 4119d84b..14b38e6c 100644 --- a/models/user.py +++ b/models/user.py @@ -11,7 +11,7 @@ import postmark from tornado.options import define, options -from lib.utilities import email_re, transform_to_square_thumbnail, utcnow +from lib.utilities import email_re, transform_to_square_thumbnail, utcnow, pretty_date from lib.flyingcow import Model, Property from lib.flyingcow.cache import ModelQueryCache from lib.flyingcow.db import IntegrityError @@ -505,10 +505,11 @@ def delete(self): service.save() user_shake = self.shake() - subscriptions = subscription.Subscription.where("user_id=%s or shake_id=%s", self.id, user_shake.id) - for sub in subscriptions: - sub.deleted = 1 - sub.save() + if user_shake: + subscriptions = subscription.Subscription.where("user_id=%s or shake_id=%s", self.id, user_shake.id) + for sub in subscriptions: + sub.deleted = 1 + sub.save() shakemanagers = shakemanager.ShakeManager.where("user_id=%s and deleted=0", self.id) for sm in shakemanagers: @@ -725,6 +726,25 @@ def update_email(self, email): self.email = email self._validate_email_uniqueness() + def get_last_activity_date(self): + sql = """SELECT max(created_at) last_activity_date FROM ( + SELECT max(created_at) created_at FROM sharedfile WHERE user_id=%s AND deleted=0 + UNION + SELECT max(created_at) created_at FROM comment WHERE user_id=%s AND deleted=0 + UNION + SELECT max(created_at) created_at FROM favorite WHERE user_id=%s AND deleted=0 + UNION + SELECT max(created_at) created_at FROM bookmark WHERE user_id=%s + ) as activities + """ + response = self.query(sql, self.id, self.id, self.id, self.id) + if response and response[0]['last_activity_date']: + return response[0]['last_activity_date'] + return None + + def pretty_created_at(self): + return pretty_date(self.created_at) + def uploaded_kilobytes(self, start_time=None, end_time=None): """ Returns the total number of kilobytes uploaded for the time period specified @@ -750,6 +770,13 @@ def uploaded_kilobytes(self, start_time=None, end_time=None): def can_post(self): return self.can_upload_this_month() + def uploaded_this_month(self): + month_days = calendar.monthrange(utcnow().year,utcnow().month) + start_time = utcnow().strftime("%Y-%m-01") + end_time = utcnow().strftime("%Y-%m-" + str(month_days[1]) ) + + return self.uploaded_kilobytes(start_time=start_time, end_time=end_time) + def can_upload_this_month(self): """ Returns if this user can upload this month. @@ -761,11 +788,7 @@ def can_upload_this_month(self): if self.is_plus(): return True - month_days = calendar.monthrange(utcnow().year,utcnow().month) - start_time = utcnow().strftime("%Y-%m-01") - end_time = utcnow().strftime("%Y-%m-" + str(month_days[1]) ) - - total_bytes = self.uploaded_kilobytes(start_time=start_time, end_time=end_time) + total_bytes = self.uploaded_this_month() if total_bytes == 0: return True diff --git a/routes.py b/routes.py index ddb1b215..0f37d6dd 100644 --- a/routes.py +++ b/routes.py @@ -218,6 +218,7 @@ (r"/admin/interesting-stats", handlers.admin.InterestingStatsHandler), (r"/admin/waitlist", handlers.admin.WaitlistHandler), (r"/admin/nsfw-users", handlers.admin.NSFWUserHandler), + (r"/admin/user/([a-zA-Z0-9_\-]+)", handlers.admin.UserHandler), (r"/admin/user/([a-zA-Z0-9_\-]+)/flag-nsfw", handlers.admin.FlagNSFWHandler), (r"/admin/delete-user", handlers.admin.DeleteUserHandler), diff --git a/static/css/admin.css b/static/css/admin.css new file mode 100644 index 00000000..977e56de --- /dev/null +++ b/static/css/admin.css @@ -0,0 +1,170 @@ +/* Admin CSS for user admin page: /admin/user/___ */ + +.admin-user .pro-badge { + line-height: 16px; + vertical-align: super; +} + +@media screen and (max-width: 589px) { + .admin-user .body { + padding: var(--size-spacing-default); + } +} + +.admin-user .recent-posts .user-and-title { + display: none; +} + +.admin-user .recent-posts { + max-width: 100%; + padding: var(--size-spacing-default); +} + +.admin-user .recent-posts .post { + margin-bottom: var(--size-spacing-default); + position: relative; + width: 270px; +} + +.admin-user .recent-posts .post .takedown { + position: absolute; + right: 2px; + top: 2px; +} + +.admin-user .recent-comments { + padding: 2px; +} + +.admin-user .recent-comments .comment { + margin-bottom: var(--size-spacing-default); + padding: var(--size-spacing-default); + word-break: break-word; +} + +.admin-user .recent-posts .post.deleted .image-medium-thumb { + border: 1px solid red; +} + +.admin-user .recent-posts .post.deleted img { + filter: grayscale(100%); +} + +.admin-user .recent-posts .post.deleted:hover .image-medium-thumb { + border: 1px solid rgba(0, 0, 0, 0); +} + +.admin-user .recent-posts .post.deleted:hover img { + filter: none; +} + +.admin-user .recent-comments .comment:nth-child(even) { + background-color: var(--color-background-content); +} + +.admin-user .user-info { + width: 100%; +} + +.admin-user .user-info th { + padding-right: 1em; + text-align: right; + vertical-align: top; + width: 33%; +} + +.admin-user .user-info td { + vertical-align: top; +} + +.admin-user .body { + position: relative; +} + +.admin-user .user-info-avatar { + position: absolute; + right: var(--size-spacing-triple); + top: var(--size-spacing-triple); + z-index: 2; /* Prevents clipping from .tabs-header which has a background assigned */ +} + +@media screen and (max-width: 589px) { + .admin-user .user-info-avatar { + right: var(--size-spacing-default); + top: var(--size-spacing-default); + } + .admin-user .user-info-avatar img { + height: 50px; + width: 50px; + } +} + +.admin-user .user-info label { + display: inline; +} + +.admin-user .tab-recent-posts, +.admin-user .tab-recent-comments { + display: none; +} + +/* Ideally, these styles should be made more generic, so we don't have to + reinvent tab styling for other situations where we want them. */ +.admin-user .tabs { + margin-bottom: var(--size-spacing-double); +} + +.admin-user .tabs .tabs-header { + background-color: var(--color-background-content); + margin-bottom: 0; + position: sticky; + top: 0; + z-index: 1; +} + +.admin-user .tabs .tabs-header ul { + font-size: 1.2rem; + list-style: none; + margin: 0; + max-width: 75vw; + overflow-x: auto; + padding: var(--size-spacing-double) 0 0 0; + white-space: nowrap; +} + +@media screen and (max-width: 589px) { + .admin-user .tabs .tabs-header ul { + max-width: 85vw; + } +} + +.admin-user .tabs .tabs-header a { + color: var(--color-text-link-primary); + text-decoration: none; +} + +.admin-user .tabs .tabs-header a:hover { + text-decoration: underline; +} + +.admin-user .tabs .tabs-header a.active { + font-weight: bold; +} + +.admin-user .tabs .tabs-header li { + background-color: var(--color-background-content-secondary); + border-radius: var(--size-border-radius-large) + var(--size-border-radius-large) 0 0; + display: inline-block; + padding: var(--size-spacing-default); + cursor: pointer; + text-decoration: none; +} + +.admin-user .tabs .tab { + background-color: var(--color-background-content-secondary); + border-radius: 0 var(--size-border-radius-large) + var(--size-border-radius-large) var(--size-border-radius-large); + margin-top: 0; + padding: var(--size-spacing-double) 0; +} diff --git a/tasks/admin.py b/tasks/admin.py new file mode 100644 index 00000000..73c392b0 --- /dev/null +++ b/tasks/admin.py @@ -0,0 +1,15 @@ +from tasks import mltshp_task +from models import User + + +@mltshp_task() +def delete_account(user_id=0, **kwargs): + """ + This task deletes a user account. This is meant to do the full deletion work of + related records for a User object that has a deleted flag already set. + + """ + user = User.get('id = %s', user_id) + if not user or user.is_admin() or user.deleted == 0: + return + user.delete() diff --git a/tasks/counts.py b/tasks/counts.py index da3b7a71..f6e3073c 100644 --- a/tasks/counts.py +++ b/tasks/counts.py @@ -46,18 +46,19 @@ def calculate_saves(sharedfile_id, **kwargs): """ db = Connection(options.database_host, options.database_name, options.database_user, options.database_password) sharedfile = db.get("select original_id from sharedfile where id = %s", sharedfile_id) - original_id = sharedfile['original_id'] - - # If this file is original, calculate it's save count by all sharedfile where this file is the original_id. - if original_id == 0: - original_saves = db.get("SELECT count(id) AS count FROM sharedfile where original_id = %s and deleted = 0", sharedfile_id) - db.execute("UPDATE sharedfile set save_count = %s WHERE id = %s", original_saves['count'], sharedfile_id) - # Otherwise, we need to update the original's save count and this file's save count. - else: - original_saves = db.get("SELECT count(id) AS count FROM sharedfile where original_id = %s and deleted = 0", original_id) - db.execute("UPDATE sharedfile set save_count = %s WHERE id = %s", original_saves['count'], original_id) + if sharedfile: + original_id = sharedfile['original_id'] - # Calc this files new save count, only based on parent since its not original. - parent_saves = db.get("SELECT count(id) AS count FROM sharedfile where parent_id = %s and deleted = 0", sharedfile_id) - db.execute("UPDATE sharedfile set save_count = %s WHERE id = %s", parent_saves['count'], sharedfile_id) + # If this file is original, calculate it's save count by all sharedfile where this file is the original_id. + if original_id == 0: + original_saves = db.get("SELECT count(id) AS count FROM sharedfile where original_id = %s and deleted = 0", sharedfile_id) + db.execute("UPDATE sharedfile set save_count = %s WHERE id = %s", original_saves['count'], sharedfile_id) + # Otherwise, we need to update the original's save count and this file's save count. + else: + original_saves = db.get("SELECT count(id) AS count FROM sharedfile where original_id = %s and deleted = 0", original_id) + db.execute("UPDATE sharedfile set save_count = %s WHERE id = %s", original_saves['count'], original_id) + + # Calc this files new save count, only based on parent since its not original. + parent_saves = db.get("SELECT count(id) AS count FROM sharedfile where parent_id = %s and deleted = 0", sharedfile_id) + db.execute("UPDATE sharedfile set save_count = %s WHERE id = %s", parent_saves['count'], sharedfile_id) db.close() diff --git a/templates/account/index.html b/templates/account/index.html index 97122b52..160d9833 100644 --- a/templates/account/index.html +++ b/templates/account/index.html @@ -88,6 +88,12 @@

Followed by ({{follower_count}})

{% if current_user_obj and current_user_obj.is_admin() and not site_is_readonly %} +
+

Admin Actions

+
+ Manage User +
+
{% end %} - {% if not current_user_obj %} -
- -
- {% end %}
{% if has_data_to_migrate %} @@ -122,12 +123,6 @@

Followed by ({{follower_count}})

{% end %}
- {% if not current_user_obj %} -
- -
- {% end %} - {{modules.Pagination(object_count=count, current_page=page, url_format=url_format)}} diff --git a/templates/admin/delete-user.html b/templates/admin/delete-user.html deleted file mode 100644 index 82758fa1..00000000 --- a/templates/admin/delete-user.html +++ /dev/null @@ -1,17 +0,0 @@ -{%extends "base.html" %} - -{% block title %}Delete User{% end %} - -{% block main %} -
-

- delete user -

-
- {{ xsrf_form_html() }} - User ID:
- User Name:
- -
-
-{% end %} diff --git a/templates/admin/image-takedown.html b/templates/admin/image-takedown.html index 66696c18..17eaa750 100644 --- a/templates/admin/image-takedown.html +++ b/templates/admin/image-takedown.html @@ -61,8 +61,8 @@

Image Takedown

This will take down a specific image from the site by share key. It will find all other instances of this same image that have been saved to other shakes, etc. - and remove those as well. A notification will be sent to users who have had this - post removed to inform them of the takedown. + and remove those as well. Please note that other saves of this post will be deleted + as well.

diff --git a/templates/admin/user.html b/templates/admin/user.html new file mode 100644 index 00000000..f2b9c76c --- /dev/null +++ b/templates/admin/user.html @@ -0,0 +1,257 @@ +{%extends "base.html" %} + +{% block title %}Admin - {{ user.name }}{% end %} + +{% block main %} +
+
+

{{ user.name }}{% if user.is_plus() %} pro{% end %}

+ + + +
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% if subscribed %} + + + + + + + + + {% else %} + + + + + {% end %} + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + {% if is_superuser %} +
+ {% if user.stripe_customer_id %} + Stripe + {% end %} + + {% if user.deleted == 0 %} + + {% end %} +
+ {% end %} +
+
+{% end %} + +{% block included_headers %} + +{% end %} + +{% block included_scripts %} + + +{% end %} diff --git a/templates/base.html b/templates/base.html index d896ab7f..541203d5 100644 --- a/templates/base.html +++ b/templates/base.html @@ -146,6 +146,11 @@ {% end %} {% end %} + {% if current_user_object and current_user_object.is_admin() %} +
  • + admin +
  • + {% end %}
  • settings
  • diff --git a/templates/uimodules/image-medium.html b/templates/uimodules/image-medium.html index 4e560e0e..92a0a3d0 100644 --- a/templates/uimodules/image-medium.html +++ b/templates/uimodules/image-medium.html @@ -1,12 +1,14 @@
    {% set sourcefile = sharedfile.sourcefile() %} - +
    + {% if sharedfile_user %} - {{escape(sharedfile_user.display_name())}} + {{escape(sharedfile_user.display_name())}} + {% end %}
    {{escape(sharedfile.get_title())}}