Use s3 direct POST for ads images.

This commit is contained in:
David Wick
2015-10-13 14:10:40 -07:00
parent 722bf21bb9
commit d96009151f
20 changed files with 928 additions and 170 deletions

View File

@@ -386,12 +386,12 @@ def make_map(config):
type='click')
mc('/api/gadget/:type', controller='api', action='gadget')
mc('/api/:action', controller='promoteapi',
requirements=dict(action=("promote|unpromote|edit_promo|link_thumb|"
"freebie|promote_note|update_pay|"
requirements=dict(action=("promote|unpromote|edit_promo|ad_s3_callback|"
"ad_s3_params|freebie|promote_note|update_pay|"
"edit_campaign|delete_campaign|"
"add_roadblock|rm_roadblock|check_inventory|"
"refund_campaign|terminate_campaign|"
"review_fraud|create_promo|link_mobile_ad_image|"
"review_fraud|create_promo|"
"toggle_pause_campaign")))
mc('/api/:action', controller='apiminimal',
requirements=dict(action="new_captcha"))

View File

@@ -24,8 +24,12 @@ from datetime import datetime, timedelta
from babel.dates import format_date
from babel.numbers import format_number
import hashlib
import hmac
import json
import urllib
import mimetypes
import os
from pylons import request
from pylons import tmpl_context as c
@@ -41,17 +45,20 @@ from r2.lib.authorize import (
PROFILE_LIMIT,
)
from r2.lib.authorize.api import AuthorizeNetException
from r2.lib import hooks, inventory, promote
from r2.lib import (
hooks,
inventory,
media,
promote,
s3_helpers,
)
from r2.lib.base import abort
from r2.lib.db import queries
from r2.lib.errors import errors
from r2.lib.filters import websafe
from r2.lib.template_helpers import format_html
from r2.lib.media import (
force_mobile_ad_image,
force_thumbnail,
thumbnail_url,
_scrape_media,
from r2.lib.filters import jssafe, scriptsafe_dumps
from r2.lib.template_helpers import (
add_sr,
format_html,
)
from r2.lib.memoize import memoize
from r2.lib.menus import NamedButton, NavButton, NavMenu, QueryButton
@@ -68,11 +75,11 @@ from r2.lib.pages import (
RenderableCampaign,
Roadblocks,
SponsorLookupUser,
UploadedImage,
)
from r2.lib.pages.things import default_thing_wrapper, wrap_links
from r2.lib.system_messages import user_added_messages
from r2.lib.utils import (
constant_time_compare,
is_subdomain,
to_date,
to36,
@@ -105,6 +112,7 @@ from r2.lib.validator import (
VModhash,
VOneOf,
VOSVersion,
VPrintable,
VPriority,
VPromoCampaign,
VPromoTarget,
@@ -143,6 +151,35 @@ ANDROID_DEVICES = ('phone', 'tablet',)
ADZERK_URL_MAX_LENGTH = 499
EXPIRES_DATE_FORMAT = "%Y-%m-%dT%H:%M:%S"
ALLOWED_IMAGE_TYPES = set(["image/jpg", "image/jpeg", "image/png"])
def _format_expires(expires):
return expires.strftime(EXPIRES_DATE_FORMAT)
def _get_callback_hmac(username, key, expires):
secret = g.secrets["s3_direct_post_callback"]
expires_str = _format_expires(expires)
data = "|".join([username, key, expires_str])
return hmac.new(secret, data, hashlib.sha256).hexdigest()
def _force_images(link, thumbnail, mobile):
changed = False
if thumbnail:
media.force_thumbnail(link, thumbnail["data"], thumbnail["ext"])
changed = True
if mobile:
media.force_mobile_ad_image(link, mobile["data"], mobile["ext"])
changed = True
return changed
def campaign_has_oversold_error(form, campaign):
if campaign.priority.inventory_override:
return
@@ -175,11 +212,71 @@ def has_oversold_error(form, campaign, start, end, bid, cpm, target, location):
return True
def _key_to_dict(key, data=False):
timer = g.stats.get_timer("providers.s3.get_ads_key_meta.with_%s" %
("data" if data else "no_data"))
timer.start()
url = key.generate_url(expires_in=0, query_auth=False)
# Generating an S3 url without authentication fails for IAM roles.
# This removes the bad query params.
# see: https://github.com/boto/boto/issues/2043
url = promote.update_query(url, {"x-amz-security-token": None}, unset=True)
result = {
"url": url,
"data": key.get_contents_as_string() if data else None,
"ext": key.get_metadata("ext"),
}
timer.stop()
return result
def _get_ads_keyspace(thing):
return "ads/%s/" % thing._fullname
def _get_ads_images(thing, data=False, **kwargs):
images = {}
timer = g.stats.get_timer("providers.s3.get_ads_image_keys")
timer.start()
keys = s3_helpers.get_keys(g.s3_client_uploads_bucket, prefix=_get_ads_keyspace(thing), **kwargs)
timer.stop()
for key in keys:
filename = os.path.basename(key.key)
name, ext = os.path.splitext(filename)
if name not in ("mobile", "thumbnail"):
continue
images[name] = _key_to_dict(key, data=data)
return images
def _clear_ads_images(thing):
timer = g.stats.get_timer("providers.s3.delete_ads_image_keys")
timer.start()
s3_helpers.delete_keys(g.s3_client_uploads_bucket, prefix=_get_ads_keyspace(thing))
timer.stop()
class PromoteController(RedditController):
@validate(VSponsor())
def GET_new_promo(self):
ads_images = _get_ads_images(c.user)
images = {k: v.get("url") for k, v in ads_images.iteritems()}
return PromotePage(title=_("create sponsored link"),
content=PromoteLinkNew(),
content=PromoteLinkNew(images),
extra_js_config={
"ads_virtual_page": "new-promo",
}).render()
@@ -672,7 +769,7 @@ class PromoteApiController(ApiController):
else:
form.set_text('.status', _('refund not needed'))
@validatedMultipartForm(
@validatedForm(
VSponsor('link_id36'),
VModhash(),
VRatelimit(rate_user=True,
@@ -697,20 +794,25 @@ class PromoteApiController(ApiController):
third_party_tracking=VUrl("third_party_tracking"),
third_party_tracking_2=VUrl("third_party_tracking_2"),
is_managed=VBoolean("is_managed"),
thumbnail_file=VUploadLength('file', 500*1024),
)
def POST_create_promo(self, form, jquery, username, title, url,
selftext, kind, disable_comments, sendreplies,
media_url, media_autoplay, media_override,
iframe_embed_url, media_url_type, domain_override,
third_party_tracking, third_party_tracking_2,
is_managed, thumbnail_file):
return self._edit_promo(form, jquery, username, title, url,
selftext, kind, disable_comments, sendreplies,
media_url, media_autoplay, media_override,
iframe_embed_url, media_url_type, domain_override,
third_party_tracking, third_party_tracking_2,
is_managed, thumbnail_file=thumbnail_file)
is_managed):
images = _get_ads_images(c.user, data=True, meta=True)
return self._edit_promo(
form, jquery, username, title, url,
selftext, kind, disable_comments, sendreplies,
media_url, media_autoplay, media_override,
iframe_embed_url, media_url_type, domain_override,
third_party_tracking, third_party_tracking_2, is_managed,
thumbnail=images.get("thumbnail", None),
mobile=images.get("mobile", None),
)
@validatedForm(
VSponsor('link_id36'),
@@ -745,22 +847,30 @@ class PromoteApiController(ApiController):
iframe_embed_url, media_url_type, domain_override,
third_party_tracking, third_party_tracking_2,
is_managed, l):
return self._edit_promo(form, jquery, username, title, url,
selftext, kind, disable_comments, sendreplies,
media_url, media_autoplay, media_override,
iframe_embed_url, media_url_type, domain_override,
third_party_tracking, third_party_tracking_2,
is_managed, l=l)
images = _get_ads_images(l, data=True, meta=True)
return self._edit_promo(
form, jquery, username, title, url,
selftext, kind, disable_comments, sendreplies,
media_url, media_autoplay, media_override,
iframe_embed_url, media_url_type, domain_override,
third_party_tracking, third_party_tracking_2, is_managed,
l=l,
thumbnail=images.get("thumbnail", None),
mobile=images.get("mobile", None),
)
def _edit_promo(self, form, jquery, username, title, url,
selftext, kind, disable_comments, sendreplies,
media_url, media_autoplay, media_override,
iframe_embed_url, media_url_type, domain_override,
third_party_tracking, third_party_tracking_2,
is_managed, l=None, thumbnail_file=None):
is_managed, l=None, thumbnail=None, mobile=None):
should_ratelimit = False
is_self = (kind == "self")
is_link = not is_self
is_new_promoted = not l
if not c.user_is_sponsor:
should_ratelimit = True
@@ -768,7 +878,7 @@ class PromoteApiController(ApiController):
c.errors.remove((errors.RATELIMIT, 'ratelimit'))
# check for user override
if not l and c.user_is_sponsor and username:
if is_new_promoted and c.user_is_sponsor and username:
try:
user = Account._by_name(username)
except NotFound:
@@ -814,7 +924,7 @@ class PromoteApiController(ApiController):
return
# users can change the disable_comments on promoted links
if ((not l or not promote.is_promoted(l)) and
if ((is_new_promoted or not promote.is_promoted(l)) and
(form.has_errors('title', errors.NO_TEXT, errors.TOO_LONG) or
jquery.has_errors('ratelimit', errors.RATELIMIT))):
return
@@ -822,7 +932,7 @@ class PromoteApiController(ApiController):
if is_self and form.has_errors('text', errors.TOO_LONG):
return
if not l:
if is_new_promoted:
# creating a new promoted link
l = promote.new_promotion(
is_self=is_self,
@@ -839,13 +949,7 @@ class PromoteApiController(ApiController):
l.third_party_tracking_2 = third_party_tracking_2 or None
l._commit()
# only set the thumbnail when creating a link
if thumbnail_file:
try:
force_thumbnail(l, thumbnail_file)
l._commit()
except IOError:
pass
_force_images(l, thumbnail=thumbnail, mobile=mobile)
form.redirect(promote.promo_edit_url(l))
@@ -860,6 +964,9 @@ class PromoteApiController(ApiController):
l.title = title
changed = True
if _force_images(l, thumbnail=thumbnail, mobile=mobile):
changed = True
# type changing
if is_self != l.is_self:
l.set_content(is_self, selftext if is_self else url)
@@ -888,13 +995,13 @@ class PromoteApiController(ApiController):
if c.user_is_sponsor and scraper_embed and media_url != l.media_url:
if media_url:
media = _scrape_media(
scraped = media._scrape_media(
media_url, autoplay=media_autoplay,
save_thumbnail=False, use_cache=True)
if media:
l.set_media_object(media.media_object)
l.set_secure_media_object(media.secure_media_object)
if scraped:
l.set_media_object(scraped.media_object)
l.set_secure_media_object(scraped.secure_media_object)
l.media_url = media_url
l.gifts_embed_url = None
l.media_autoplay = media_autoplay
@@ -957,6 +1064,11 @@ class PromoteApiController(ApiController):
l.managed_promo = is_managed
l._commit()
# clean up so the same images don't reappear if they create
# another link
_clear_ads_images(thing=c.user if is_new_promoted else l)
form.redirect(promote.promo_edit_url(l))
@validatedForm(
@@ -1361,36 +1473,77 @@ class PromoteApiController(ApiController):
else:
_handle_failed_payment()
@validate(
VSponsor("link_name"),
@json_validate(
VSponsor("link"),
VModhash(),
link=VByName('link_name'),
file=VUploadLength('file', 500*1024),
img_type=VImageType('img_type'),
link=VLink("link"),
kind=VOneOf("kind", ["thumbnail", "mobile"]),
filepath=nop("filepath"),
ajax=VBoolean("ajax", default=True)
)
def POST_link_thumb(self, link=None, file=None, img_type='jpg'):
if not link or (promote.is_promoted(link) and not c.user_is_sponsor):
# only let sponsors edit thumbnails of live promos
return abort(403, 'forbidden')
def POST_ad_s3_params(self, responder, link, kind, filepath, ajax):
filename, ext = os.path.splitext(filepath)
mime_type, encoding = mimetypes.guess_type(filepath)
force_thumbnail(link, file, file_type=".%s" % img_type)
link._commit()
return UploadedImage(_('saved'), thumbnail_url(link), "", errors=errors,
form_id="image-upload").render()
if not mime_type or mime_type not in ALLOWED_IMAGE_TYPES:
request.environ["extra_error_data"] = {
"message": _("image must be a jpg or png"),
}
abort(403)
keyspace = _get_ads_keyspace(link if link else c.user)
key = os.path.join(keyspace, kind)
redirect = None
if not ajax:
now = datetime.now().replace(tzinfo=g.tz)
signature = _get_callback_hmac(
username=c.user.name,
key=key,
expires=now,
)
path = ("/api/ad_s3_callback?hmac=%s&ts=%s" %
(signature, _format_expires(now)))
redirect = add_sr(path, sr_path=False)
return s3_helpers.get_post_args(
bucket=g.s3_client_uploads_bucket,
key=key,
success_action_redirect=redirect,
success_action_status="201",
content_type=mime_type,
meta={
"x-amz-meta-ext": ext,
},
)
@validate(
VSponsor("link_name"),
VModhash(),
link=VByName('link_name'),
file=VUploadLength('file', 500*1024),
img_type=VImageType('img_type'),
VSponsor(),
expires=VDate("ts", format=EXPIRES_DATE_FORMAT),
signature=VPrintable("hmac", 255),
callback=nop("callback"),
key=nop("key"),
)
def POST_link_mobile_ad_image(self, link=None, file=None, img_type='jpg'):
if not (link and c.user_is_sponsor and file):
# only sponsors can set the mobile img
return abort(403, 'forbidden')
def GET_ad_s3_callback(self, expires, signature, callback, key):
now = datetime.now(tz=g.tz)
if (expires + timedelta(minutes=10) < now):
self.abort404()
force_mobile_ad_image(link, file, file_type=".%s" % img_type)
link._commit()
return UploadedImage(_('saved'), link.mobile_ad_url, "", errors=errors,
form_id="mobile-ad-image-upload").render()
expected_mac = _get_callback_hmac(
username=c.user.name,
key=key,
expires=expires,
)
if not constant_time_compare(signature, expected_mac):
self.abort404()
template = "<script>parent.__s3_callbacks__[%(callback)s](%(data)s);</script>"
image = _key_to_dict(
s3_helpers.get_key(g.s3_client_uploads_bucket, key))
response = {
"callback": scriptsafe_dumps(callback),
"data": scriptsafe_dumps(image),
}
return format_html(template, response)

View File

@@ -547,6 +547,7 @@ module["sponsored"] = LocalizedModule("sponsored.js",
"lib/ui.core.js",
"lib/ui.datepicker.js",
"lib/react-with-addons-0.11.2.js",
"image-upload.js",
"sponsored.js"
)

View File

@@ -4356,7 +4356,10 @@ class PromoteLinkBase(Templated):
class PromoteLinkNew(PromoteLinkBase):
pass
def __init__(self, images=None, *a, **kw):
images = images or {}
self.images = images
PromoteLinkBase.__init__(self, **kw)
class PromoteLinkEdit(PromoteLinkBase):

View File

@@ -180,10 +180,14 @@ def is_pending(campaign):
today = promo_datetime_now().date()
return today < to_date(campaign.start_date)
def update_query(base_url, query_updates):
def update_query(base_url, query_updates, unset=False):
scheme, netloc, path, params, query, fragment = urlparse.urlparse(base_url)
query_dict = urlparse.parse_qs(query)
query_dict.update(query_updates)
if unset:
query_dict = dict((k, v) for k, v in query_dict.iteritems() if v is not None)
query = urllib.urlencode(query_dict, doseq=True)
return urlparse.urlunparse((scheme, netloc, path, params, query, fragment))

View File

@@ -20,10 +20,17 @@
# Inc. All Rights Reserved.
###############################################################################
import base64
import boto
import hashlib
import hmac
import json
import os
import sys
import time
from pylons import app_globals as g
from boto.s3.key import Key
HADOOP_FOLDER_SUFFIX = '_$folder$'
@@ -62,7 +69,7 @@ def get_text_from_s3(s3_connection, path):
"""Read a file from S3 and return it as text."""
bucket_name, key_name = _from_path(path)
bucket = s3_connection.get_bucket(bucket_name)
k = Key(bucket)
k = boto.s3.Key(bucket)
k.key = key_name
txt = k.get_contents_as_string()
return txt
@@ -74,7 +81,7 @@ def mv_file_s3(s3_connection, src_path, dst_path):
dst_bucket_name, dst_key_name = _from_path(dst_path)
src_bucket = s3_connection.get_bucket(src_bucket_name)
k = Key(src_bucket)
k = boto.s3.Key(src_bucket)
k.key = src_key_name
k.copy(dst_bucket_name, dst_key_name)
k.delete()
@@ -100,7 +107,7 @@ def copy_to_s3(s3_connection, local_path, dst_path, verbose=False):
return
key_name = os.path.join(dst_key_name, filename)
k = Key(bucket)
k = boto.s3.Key(bucket)
k.key = key_name
kw = {}
@@ -109,3 +116,187 @@ def copy_to_s3(s3_connection, local_path, dst_path, verbose=False):
kw['cb'] = callback
k.set_contents_from_filename(logfile, **kw)
def get_connection():
return boto.connect_s3(g.S3KEY_ID or None, g.S3SECRET_KEY or None)
def get_key(bucket_name, key, connection=None):
connection = connection or get_connection()
bucket = connection.get_bucket(bucket_name)
return bucket.get_key(key)
def get_keys(bucket_name, meta=False, connection=None, **kwargs):
connection = connection or get_connection()
bucket = connection.get_bucket(bucket_name)
keys = bucket.get_all_keys(**kwargs)
if not meta:
return keys
return [bucket.get_key(key.name)
for key in keys]
def delete_keys(bucket_name, prefix, connection=None):
connection = connection or get_connection()
keys = get_keys(bucket_name, prefix=prefix, connection=connection)
return connection.get_bucket(bucket_name).delete_keys(keys)
def _get_upload_policy(
bucket, key, acl, ttl=60,
success_action_redirect=None,
success_action_status="201",
content_type=None,
max_content_length=((1024**2) * 3),
storage_class="STANDARD",
meta=None,
connection=None,
):
connection = connection or get_connection()
meta = meta or {}
expiration = time.gmtime(int(time.time() + ttl))
conditions = []
conditions.append({"bucket": bucket})
if key.endswith("${filename}"):
conditions.append(["starts-with", "$key", key[:-len("${filename}")]])
else:
conditions.append({"key": key})
conditions.append({"acl": acl})
conditions.append({"x-amz-storage-class": storage_class})
if success_action_redirect:
conditions.append([
"starts-with",
"$success_action_redirect",
success_action_redirect,
])
else:
conditions.append({
"success_action_status": success_action_status,
})
conditions.append([
"content-length-range", 0, max_content_length])
for key, value in meta.iteritems():
conditions.append({key: value})
if content_type:
conditions.append({"content-type": content_type})
return base64.b64encode(json.dumps({
"expiration": time.strftime(boto.utils.ISO8601, expiration),
"conditions": conditions,
}))
def _get_upload_signature(
policy,
connection=None,
):
connection = connection or get_connection()
key = connection.provider.secret_key.encode("utf-8")
hashed = hmac.new(key, policy, hashlib.sha1)
return base64.encodestring(
hashed.digest()).decode("utf-8").strip()
def get_post_args(
bucket, key,
acl="public-read",
success_action_redirect=None,
success_action_status="201",
content_type=None,
storage_class="STANDARD",
meta=None,
connection=None,
**kwargs
):
meta = meta or []
connection = connection or get_connection()
policy = _get_upload_policy(
bucket=bucket,
key=key,
acl=acl,
success_action_redirect=success_action_redirect,
success_action_status=success_action_status,
content_type=content_type,
storage_class=storage_class,
meta=meta,
connection=connection,
)
signature = _get_upload_signature(
policy, connection=connection)
fields = []
fields.append({
"name": "AWSAccessKeyId",
"value": connection.provider.access_key,
})
fields.append({
"name": "acl",
"value": acl,
})
fields.append({
"name": "key",
"value": key,
})
if success_action_redirect:
fields.append({
"name": "success_action_redirect",
"value": success_action_redirect,
})
else:
fields.append({
"name": "success_action_status",
"value": success_action_status,
})
fields.append({
"name": "content-type",
"value": content_type,
})
fields.append({
"name": "x-amz-storage-class",
"value": storage_class,
})
for key, value in meta.iteritems():
fields.append({
"name": key,
"value": value,
})
fields.append({
"name": "policy",
"value": policy,
})
fields.append({
"name": "signature",
"value": signature,
})
return {
"action": "//%s.%s" % (bucket, g.s3_media_domain),
"fields": fields,
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -13,3 +13,5 @@
@import "toggles.less";
@import "read-next.less";
@import "infobar.less";
@import "progress.less";
@import "image-upload.less";

View File

@@ -0,0 +1,38 @@
.c-image-upload-input {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: block;
margin: 0;
opacity: 0;
padding: 0;
width: 100%;
&:hover {
cursor: pointer;
}
}
.c-image-upload-preview-container {
border-width: 2px;
border-style: dashed;
border-color: lightgray;
display: inline-block;
margin: 5px 0;
overflow: hidden;
padding: 5px;
position: relative;
}
.c-image-upload-preview {
display: block;
margin: 0 !important;
}
.c-image-upload-btn {
display: block;
padding: 2px 8px !important;
font-size: 10px !important;;
}

View File

@@ -0,0 +1,19 @@
.c-progress {
display: none;
height: 2px;
overflow: hidden;
background-color: #f5f5f5;
.box-shadow(~"inset 0 1px 2px rgba(0,0,0,.1)");
position: relative;
margin-top: -2px;
}
.c-progress-bar {
float: left;
width: 0;
height: 100%;
line-height: 2px;
background-color: #337ab7;
.box-shadow(~"inset 0 -1px 0 rgba(0,0,0,.15)");
.transition-shorthand(width .6s ease);
}

View File

@@ -0,0 +1,288 @@
!(function(global, $, r, undefined) {
'use strict';
var CALLBACK_PREFIX = '__image_upload__';
var DEFAULTS = {
max: 1024 * 500,
errors: {
unknown: r._('something went wrong.'),
}
};
var ImageUpload = function(element, options) {
this.initialize(element, options);
};
_.extend(ImageUpload.prototype, {
_callbacks: {},
initialize: function(element, options) {
this.el = element;
this.$el = $(element);
this.$file = this.$el.find('[type="file"]');
this.file = this.$file.get(0);
this.options = _.defaults({}, this.$el.data(), options, DEFAULTS);
this._bindEvents();
this.ajax = !!global.FormData;
return this;
},
_bindEvents: function() {
this.$file.on('change', this._handleChange.bind(this));
this.$el.find('.c-image-upload-btn')
.on('click',this._triggerDialog.bind(this));
},
_triggerDialog: function() {
this.$file.click();
},
_handleChange: function() {
if (this.file.value) {
var files = this.file.files;
if (files && files.length && files[0].size > this.options.maxSize) {
this.$el.trigger('failed.imageUpload', [{
message: r._('too big. keep it under ' + r.utils.formatFileSize(this.options.maxSize)),
}]);
this._reset();
} else {
$.ajax({
url: this.options.url,
type: 'POST',
dataType: 'json',
data: _.extend({}, this.options.params, {
filepath: files[0].name,
uh: reddit.modhash,
ajax: this.ajax,
raw_json: '1',
}),
})
.fail(function(xhr) {
var resp = xhr.responseJSON;
this.$el.trigger('failed.imageUpload', [{
message: resp.message || this.options.errors.unknown,
}]);
}.bind(this))
.done(this._submit.bind(this));
}
}
return true;
},
_updateProgress: function(percentage) {
this._showProgress();
if (percentage) {
this._percentage = percentage;
this.$el.find('.c-progress-bar').css({ width: (percentage + '%') });
}
},
_showProgress: function() {
this.$el.find('.c-progress').show();
},
_hideProgress: function() {
this.$el.find('.c-progress').hide();
this.$el.find('.c-progress-bar').css({ width: 0 });
},
_submit: function(overrides) {
overrides = overrides || {};
this.$el.attr('action', overrides.action);
overrides.fields.forEach(function(field) {
var name = field.name;
var value = field.value;
var unset = value === '' || value === null
var $input = this.$el.find('[name="' + name + '"]');
if (!$input.length) {
// Skip null values
if (unset) {
return;
}
$('<input type="hidden">')
.attr('name', name)
.val(value)
.insertBefore(this.$file);
} else {
if (unset) {
$input.remove();
} else {
$input.val(value);
}
}
}.bind(this));
if (!this.ajax) {
var $redirect = this.$el.find('[name="success_action_redirect"]');
var redirect = $redirect.val();
var callback = _.uniqueId(CALLBACK_PREFIX);
redirect = r.utils.replaceUrlParams(redirect, {callback: callback});
$redirect.val(redirect);
global.__s3_callbacks__[callback] = this._callbacks[callback] = this._iframeComplete.bind(this);
this._fakeProgressTo(10, 80);
this.$el.submit();
} else {
var fields = {};
var data = new FormData();
var fileInput;
this.$el.find('input').each(function() {
var el = this;
var $el = $(el);
var type = $el.attr('type');
if (type !== 'file') {
data.append($el.attr('name'), $el.val());
}
});
data.append(this.file.name, this.file.files[0]);
this._showProgress();
$.ajax({
url: this.$el.attr('action'),
type: this.$el.attr('method'),
contentType: false,
processData: false,
data: data,
dataType: 'xml',
success: this._ajaxSuccess.bind(this),
error: this._ajaxError.bind(this),
progress: this._ajaxProgress.bind(this),
complete: this._reset.bind(this),
xhr: function() {
var xhr = $.ajaxSettings.xhr();
if (xhr instanceof global.XMLHttpRequest) {
xhr.addEventListener('progress', this.progress, false);
}
if (xhr.upload) {
xhr.upload.addEventListener('progress', this.progress, false);
}
return xhr;
},
});
}
this.$el.trigger('uploading.imageUpload');
},
_fakeProgressTo: function(start, end) {
var _fakeProgressInterval = this._fakeProgress = setInterval(function() {
if (this._percentage > end) {
clearInterval(_fakeProgressInterval);
return;
}
this._updateProgress((this._percentage || start) + 1);
}.bind(this), 200);
},
_ajaxProgress: function(e) {
var percentage = Math.round((e.loaded / e.total) * 100);
this._updateProgress(percentage);
this.$el.trigger('progress.imageUpload', [{
complete: e.loaded,
total: e.total,
percentage: percentage,
}]);
},
_updatePreview: function (url, callback) {
callback = callback || $.noop;
var $preview = this.$el.find('.c-image-upload-preview');
var src = r.utils.replaceUrlParams(url, {
cb: (+new Date()),
});
$preview.one('load', callback.bind(this));
$preview.attr('src', src);
},
_ajaxSuccess: function(xml) {
var $xml = $(xml);
var url = $xml.find('Location').text();
this._updatePreview(url, this._hideProgress);
this._updateProgress(100);
this.$el.trigger('success.imageUpload', [{
url: url,
}]);
},
_ajaxError: function(xhr) {
var $xml = $(xhr.responseXML);
var message = this.options.errors.unknown;
if ($xml && $xml.length) {
message = $xml.find('Message').text();
}
this._hideProgress();
this.$el.trigger('failed.imageUpload', [{
message: message,
}]);
},
_iframeComplete: function(data) {
this._updateProgress(100);
this._updatePreview(data.url, this._hideProgress);
},
_reset: function() {
this.$file.resetInput();
this.$el.trigger('reset.imageUpload');
},
});
function Plugin(option /* ,args... */) {
var args = _.toArray(arguments).slice(1);
if (option && /^get/.test(option)) {
var data = this.data('c.imageUpload');
return data && data[option].apply(data, args);
}
return this.each(function() {
var $el = $(this);
var data = $el.data('c.imageUpload');
var options = typeof option === 'object' && option;
if (!data) {
data = new ImageUpload(this, options);
$el.data('c.imageUpload', data);
}
if (typeof option === 'string') {
data[option].apply(data, args);
}
});
};
$.fn.imageUpload = Plugin;
$.fn.imageUpload.Constructor = ImageUpload;
})(this, this.jQuery, this.r);

View File

@@ -322,6 +322,14 @@ $.fn.updateThing = function(update) {
}
}
$.fn.resetInput = function() {
var $el = $(this);
$el.wrap('<form>').closest('form').get(0).reset();
$el.unwrap();
return this;
};
$.fn.show_unvotable_message = function() {
$(this).thing().find(".entry:first .unvotable-message").css("display", "inline-block");
};

View File

@@ -500,6 +500,16 @@ var exports = r.sponsored = {
this.inventory = {}
this.campaignListColumns = $('.existing-campaigns thead th').length
$("input[name='media_url_type']").on("change", this.mediaInputChange)
this.initUploads();
},
initUploads: function() {
$('.c-image-upload')
.imageUpload()
.on('failed.imageUpload', function(e, data) {
alert(data.message);
});
},
setup: function(inventory_by_sr, priceDict, isEmpty, userIsSponsor) {

View File

@@ -20,6 +20,15 @@ r.utils = {
return a.href;
},
// Returns human readable file sizes
// http://stackoverflow.com/a/25613067/704286
formatFileSize: function(size) {
var suffixes = ['bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'EiB', 'ZiB'];
var order = size ? parseInt(Math.log2(size) / 10, 10) : 0;
return (size / (1 << (order * 10))).toFixed(3).replace(/\.?0+$/, '') + ' ' + suffixes[order];
},
fullnameToId: function(fullname) {
var parts = fullname.split('_');
var id36 = parts && parts[1];

View File

@@ -23,7 +23,7 @@
<%!
from r2.config import feature
from r2.lib.media import thumbnail_url
from r2.lib.filters import jssafe
from r2.lib.filters import jssafe, scriptsafe_dumps
from r2.lib.pages import UserText
import simplejson
from babel.numbers import format_currency
@@ -170,68 +170,59 @@
</%utils:line_field>
</%def>
<%def name="image_field(link=None)">
<%def name="image_field(link=None, images=None)">
<%
if link:
current_image = getattr(link, 'thumbnail_url', '')
current_mobile_ad_image = getattr(link, 'mobile_ad_url', '')
link_name = link._fullname
thumbnail_url = getattr(link, "thumbnail_url", None)
mobile_url = getattr(link, "mobile_ad_url", None)
path = "ads/%s" % link._id36
else:
current_image = ''
current_mobile_ad_image =''
link_name = ''
thumbnail_url = images.get("thumbnail", None)
mobile_url = images.get("mobile", None)
path = "ads/%s" % c.user.name
%>
<%utils:line_field title="${_('thumbnail')}"
css_class="rounded image-field ${'has-image' if current_image else ''}">
<%utils:line_field
title="${_('thumbnail')}"
css_class="rounded image-field">
<div class="infotext">
${_('images will be resized if larger than 140x140 pixels (displayed at 70x70)')}
</div>
<div class="delete-field">
%if link_name:
<%utils:image_upload post_target="/api/link_thumb"
form_id="image-upload"
current_image="${current_image}"
label="${_('upload header image:')}"
ask_type="True">
<input type="hidden" name="link_name" value="${link_name}" />
</%utils:image_upload>
%else:
${utils.image_upload_inline()}
%endif
<div class="clearleft"></div>
</div>
${utils.s3_image_upload(
id="thumbnail",
width="70",
height="70",
src=thumbnail_url,
data=dict(
max=(1024**2) * 3,
url="/api/ad_s3_params.json",
params=simplejson.dumps(dict(
kind="thumbnail",
link=(link and link._id36),
)),
),
)}
</%utils:line_field>
%if c.user_is_sponsor and link_name:
<%utils:line_field title="${_('mobile ad image')}"
css_class="rounded image-field">
<div class="infotext">
${_('upload image for use on mobile web. should be exactly 1200x628 pixels (displayed at 600x314)')}
</div>
<%utils:image_upload post_target="/api/link_mobile_ad_image"
form_id="mobile-ad-image-upload"
current_image="${current_mobile_ad_image}"
label="${_('upload mobile ad image:')}"
ask_type="True">
<input type="hidden" name="link_name" value="${link_name}" />
</%utils:image_upload>
</%utils:line_field>
%endif
## overwrite the completed image function
<script type="text/javascript">
completedUploadImage = (function(cu) {
return function(status, img_src, name, errors, form_id) {
cu(status, "", "", errors, form_id);
if (form_id === 'image-upload') {
$.things('${jssafe(link_name)}')
.find(".thumbnail img")
.attr("src", img_src + "?v=" + Math.random());
}
}
})(completedUploadImage);
</script>
<%utils:line_field title="${_('mobile ad image')}"
css_class="rounded image-field">
<div class="infotext">
${_('upload image for use on mobile web. should be exactly 1200x628 pixels (displayed at 600x314)')}
</div>
${utils.s3_image_upload(
id="mobile",
width="600",
height="314",
src=mobile_url,
data=dict(
max=(1024**2) * 3,
url="/api/ad_s3_params.json",
params=simplejson.dumps(dict(
kind="mobile",
link=(link and link._id36),
)),
),
)}
</%utils:line_field>
</%def>
<%def name="commenting_field(link)">

View File

@@ -184,12 +184,10 @@ ${pr.javascript_setup()}
</footer>
</div>
<div class="uncollapsed-display" style="display:none">
%if editable:
<div class="editor-group">
${pr.image_field(thing.link)}
</div>
%endif
<div class="editor-group">
%if editable:
${pr.image_field(thing.link)}
%endif
<% is_link = not thing.link.is_self %>
${pr.title_field(thing.link, editable=editable)}
${pr.content_field(thing.link, editable=editable, enable_override=c.user_is_sponsor)}

View File

@@ -20,10 +20,15 @@
## reddit Inc. All Rights Reserved.
###############################################################################
<%!
from r2.lib import js
%>
<%namespace file="promotelinkbase.html" import="title_field, content_field, managed_field, image_field" />
<%namespace file="utils.html" import="error_field" />
<%namespace name="utils" file="utils.html"/>
${unsafe(js.use('sponsored'))}
<div class="create-promotion sponsored-page">
<div class="dashboard">
@@ -31,39 +36,33 @@
<h2>new promotion</h2>
</header>
<div class="dashboard-content">
<form method="post" action="/api/create_promo"
class="pretty-form promotelink-editor editor"
id="promo-form"
target="upload-iframe"
enctype="multipart/form-data">
<div class="pretty-form promotelink-editor editor" id="promo-form">
## need to set the modhash because we're not using a helper method to post the form
<input type="hidden" name="uh" value="${c.modhash}">
<input type="hidden" name="id" value="#promo-form">
<div class="editor-group">
${image_field()}
</div>
<div class="editor-group">
%if c.user_is_sponsor:
${username_field()}
${managed_field(None)}
%endif
${title_field(None, editable=True)}
${content_field(None, editable=True, enable_override=c.user_is_sponsor,
tracker_access=c.user_can_track_ads)}
<footer class="buttons">
<div class="rules">
By clicking "next" you agree to the&#32;<a href="https://www.reddit.com/wiki/selfserve#wiki_online_self_serve_advertising_rules" target="_blank">Self Serve Advertising Rules.</a>
</div>
${error_field("RATELIMIT", "ratelimit")}
&#32;
<span class="status error"></span>
${error_field("RATELIMIT", "ratelimit")}
<button name="create" class="btn primary-button" type="submit">
${_("next")}
</button>
</footer>
${image_field(images=thing.images)}
%if c.user_is_sponsor:
${username_field()}
${managed_field(None)}
%endif
${title_field(None, editable=True)}
${content_field(None, editable=True, enable_override=c.user_is_sponsor,
tracker_access=c.user_can_track_ads)}
<footer class="buttons">
<div class="rules">
By clicking "next" you agree to the&#32;<a href="http://www.reddit.com/wiki/selfserve#wiki_online_self_serve_advertising_rules" target="_blank">Self Serve Advertising Rules.</a>
</div>
${error_field("RATELIMIT", "ratelimit")}
&#32;
<span class="status error"></span>
${error_field("RATELIMIT", "ratelimit")}
<button
name="create" class="btn primary-button" type="button"
onclick="return post_pseudo_form('#promo-form', 'create_promo')">
${_("next")}
</button>
</footer>
</div>
</form>
<iframe src="about:blank" width="600" height="200"
@@ -85,3 +84,7 @@
</div>
</%utils:line_field>
</%def>
<script type="text/javascript">
r.sponsored.initUploads();
</script>

View File

View File

@@ -21,13 +21,20 @@
###############################################################################
<%!
import json
from r2.models import FakeSubreddit
from r2.lib.filters import spaceCompress, unsafe, safemarkdown, jssafe
from r2.lib.template_helpers import add_sr, js_config, static, html_datetime, simplified_timesince, make_url_protocol_relative
from r2.lib.utils import cols, long_datetime
from r2.lib import tracking
from datetime import datetime
import json
from r2.models import FakeSubreddit
from r2.lib.filters import spaceCompress, unsafe, safemarkdown, jssafe
from r2.lib.template_helpers import (
add_sr,
html_datetime,
js_config,
make_url_protocol_relative,
simplified_timesince,
static,
)
from r2.lib.utils import cols, long_datetime
from r2.lib import tracking
from datetime import datetime
%>
<%def name="tags(**kw)">
%for k, v in kw.iteritems():
@@ -291,6 +298,39 @@ ${unsafe(txt)}
</div>
</%def>
<%def name="s3_image_upload(id, width, height, src=None, data=None)">
<form
id="${id}"
class="c-image-upload"
method="POST"
enctype="multipart/form-data"
target="${id}-frame"
${tags(**dict(data=data))}
>
<div class="c-image-upload-preview-container">
<img
alt="${id} preview"
class="c-image-upload-preview"
style="width: ${width}px; max-height: ${height}px"
%if src:
src="${make_url_protocol_relative(src)}"
%else:
src="${static(width + 'x' + height + '-placeholder.png')}"
%endif
>
<input type="file" name="file" id="${id}-input" class="c-image-upload-input" title="${_("click to upload")}">
<div class="c-progress">
<div class="c-progress-bar"></div>
</div>
</div>
<button class="c-image-upload-btn">${_("upload")}</button>
</form>
<iframe id="${id}-frame" name="${id}-frame" src="about:blank" style="display:none;"></iframe>
%if caller:
${caller.body()}
%endif
</%def>
<%def name="image_upload(post_target, current_image = None, onsubmit = '',
onchange = '', label = '', form_id = 'image-upload',
ask_type = False, hidden_data=None)">