forked from reddit-archive/reddit
/
account.py
541 lines (440 loc) · 16.7 KB
/
account.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
# The contents of this file are subject to the Common Public Attribution
# License Version 1.0. (the "License"); you may not use this file except in
# compliance with the License. You may obtain a copy of the License at
# http://code.reddit.com/LICENSE. The License is based on the Mozilla Public
# License Version 1.1, but Sections 14 and 15 have been added to cover use of
# software over a computer network and provide for limited attribution for the
# Original Developer. In addition, Exhibit A has been modified to be consistent
# with Exhibit B.
#
# Software distributed under the License is distributed on an "AS IS" basis,
# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for
# the specific language governing rights and limitations under the License.
#
# The Original Code is Reddit.
#
# The Original Developer is the Initial Developer. The Initial Developer of the
# Original Code is CondeNet, Inc.
#
# All portions of the code written by CondeNet are Copyright (c) 2006-2010
# CondeNet, Inc. All Rights Reserved.
################################################################################
from r2.lib.db.thing import Thing, Relation, NotFound
from r2.lib.db.operators import lower
from r2.lib.db.userrel import UserRel
from r2.lib.memoize import memoize
from r2.lib.utils import modhash, valid_hash, randstr, timefromnow
from r2.lib.utils import UrlParser, is_banned_email
from r2.lib.cache import sgm
from pylons import g
import time, sha
from copy import copy
from datetime import datetime, timedelta
class AccountExists(Exception): pass
class Account(Thing):
_data_int_props = Thing._data_int_props + ('link_karma', 'comment_karma',
'report_made', 'report_correct',
'report_ignored', 'spammer',
'reported')
_int_prop_suffix = '_karma'
_defaults = dict(pref_numsites = 25,
pref_frame = False,
pref_frame_commentspanel = False,
pref_newwindow = False,
pref_clickgadget = 5,
pref_public_votes = False,
pref_hide_ups = False,
pref_hide_downs = False,
pref_min_link_score = -4,
pref_min_comment_score = -4,
pref_num_comments = g.num_comments,
pref_lang = g.lang,
pref_content_langs = (g.lang,),
pref_over_18 = False,
pref_compress = False,
pref_organic = True,
pref_no_profanity = False,
pref_label_nsfw = True,
pref_show_stylesheets = True,
pref_mark_messages_read = True,
pref_threaded_messages = True,
pref_collapse_read_messages = False,
pref_private_feeds = True,
trusted_sponsor = False,
reported = 0,
report_made = 0,
report_correct = 0,
report_ignored = 0,
spammer = 0,
sort_options = {},
has_subscribed = False,
pref_media = 'subreddit',
share = {},
wiki_override = None,
email = "",
email_verified = False,
ignorereports = False,
pref_show_promote = None,
)
def karma(self, kind, sr = None):
suffix = '_' + kind + '_karma'
#if no sr, return the sum
if sr is None:
total = 0
for k, v in self._t.iteritems():
if k.endswith(suffix):
total += v
return total
else:
try:
return getattr(self, sr.name + suffix)
except AttributeError:
#if positive karma elsewhere, you get min_up_karma
if self.karma(kind) > 0:
return g.MIN_UP_KARMA
else:
return 0
def incr_karma(self, kind, sr, amt):
prop = '%s_%s_karma' % (sr.name, kind)
if hasattr(self, prop):
return self._incr(prop, amt)
else:
default_val = self.karma(kind, sr)
setattr(self, prop, default_val + amt)
self._commit()
@property
def link_karma(self):
return self.karma('link')
@property
def comment_karma(self):
return self.karma('comment')
@property
def safe_karma(self):
karma = self.link_karma
return max(karma, 1) if karma > -1000 else karma
def can_wiki(self):
if self.wiki_override is not None:
return self.wiki_override
else:
return (self.link_karma >= g.WIKI_KARMA and
self.comment_karma >= g.WIKI_KARMA)
def jury_betatester(self):
if g.cache.get("jury-killswitch"):
return False
else:
return True
def all_karmas(self):
"""returns a list of tuples in the form (name, link_karma,
comment_karma)"""
link_suffix = '_link_karma'
comment_suffix = '_comment_karma'
karmas = []
sr_names = set()
for k in self._t.keys():
if k.endswith(link_suffix):
sr_names.add(k[:-len(link_suffix)])
elif k.endswith(comment_suffix):
sr_names.add(k[:-len(comment_suffix)])
for sr_name in sr_names:
karmas.append((sr_name,
self._t.get(sr_name + link_suffix, 0),
self._t.get(sr_name + comment_suffix, 0)))
karmas.sort(key = lambda x: abs(x[1] + x[2]), reverse=True)
karmas.insert(0, ('total',
self.karma('link'),
self.karma('comment')))
karmas.append(('old',
self._t.get('link_karma', 0),
self._t.get('comment_karma', 0)))
return karmas
def update_last_visit(self, current_time):
from admintools import apply_updates
apply_updates(self)
prev_visit = getattr(self, 'last_visit', None)
if prev_visit and current_time - prev_visit < timedelta(0, 3600):
return
g.log.debug ("Updating last visit for %s" % self.name)
self.last_visit = current_time
self._commit()
def make_cookie(self, timestr = None, admin = False):
if not self._loaded:
self._load()
timestr = timestr or time.strftime('%Y-%m-%dT%H:%M:%S')
id_time = str(self._id) + ',' + timestr
to_hash = ','.join((id_time, self.password, g.SECRET))
if admin:
to_hash += 'admin'
return id_time + ',' + sha.new(to_hash).hexdigest()
def needs_captcha(self):
return self.link_karma < 1
def modhash(self, rand=None, test=False):
return modhash(self, rand = rand, test = test)
def valid_hash(self, hash):
return valid_hash(self, hash)
@classmethod
@memoize('account._by_name')
def _by_name_cache(cls, name, allow_deleted = False):
#relower name here, just in case
deleted = (True, False) if allow_deleted else False
q = cls._query(lower(Account.c.name) == name.lower(),
Account.c._spam == (True, False),
Account.c._deleted == deleted)
q._limit = 1
l = list(q)
if l:
return l[0]._id
@classmethod
def _by_name(cls, name, allow_deleted = False, _update = False):
#lower name here so there is only one cache
uid = cls._by_name_cache(name.lower(), allow_deleted, _update = _update)
if uid:
return cls._byID(uid, True)
else:
raise NotFound, 'Account %s' % name
# Admins only, since it's not memoized
@classmethod
def _by_name_multiple(cls, name):
q = cls._query(lower(Account.c.name) == name.lower(),
Account.c._spam == (True, False),
Account.c._deleted == (True, False))
return list(q)
@property
def friends(self):
return self.friend_ids()
def delete(self):
self._deleted = True
self._commit()
#update caches
Account._by_name(self.name, allow_deleted = True, _update = True)
#we need to catch an exception here since it will have been
#recently deleted
try:
Account._by_name(self.name, _update = True)
except NotFound:
pass
#remove from friends lists
q = Friend._query(Friend.c._thing2_id == self._id,
Friend.c._name == 'friend',
eager_load = True)
for f in q:
f._thing1.remove_friend(f._thing2)
@property
def subreddits(self):
from subreddit import Subreddit
return Subreddit.user_subreddits(self)
def recent_share_emails(self):
return self.share.get('recent', set([]))
def add_share_emails(self, emails):
if not emails:
return
if not isinstance(emails, set):
emails = set(emails)
self.share.setdefault('emails', {})
share = self.share.copy()
share_emails = share['emails']
for e in emails:
share_emails[e] = share_emails.get(e, 0) +1
share['recent'] = emails
self.share = share
def set_cup(self, cup_info):
from r2.lib.template_helpers import static
if cup_info is None:
return
if cup_info.get("expiration", None) is None:
return
cup_info.setdefault("label_template",
"%(user)s recently won a trophy! click here to see it.")
cup_info.setdefault("img_url", static('award.png'))
existing_info = self.cup_info()
if (existing_info and
existing_info["expiration"] > cup_info["expiration"]):
# The existing award has a later expiration,
# so it trumps the new one as far as cups go
return
td = cup_info["expiration"] - timefromnow("0 seconds")
cache_lifetime = td.seconds
if cache_lifetime <= 0:
g.log.error("Adding a cup that's already expired?")
else:
g.hardcache.set("cup_info-%d" % self._id, cup_info, cache_lifetime)
def remove_cup(self):
g.hardcache.delete("cup_info-%d" % self._id)
def cup_info(self):
return g.hardcache.get("cup_info-%d" % self._id)
def quota_key(self, kind):
return "user_%s_quotas-%s" % (kind, self.name)
def clog_quota(self, kind, item):
key = self.quota_key(kind)
fnames = g.hardcache.get(key, [])
fnames.append(item._fullname)
g.hardcache.set(key, fnames, 86400 * 30)
def quota_baskets(self, kind):
from r2.models.admintools import filter_quotas
key = self.quota_key(kind)
fnames = g.hardcache.get(key)
if not fnames:
return None
unfiltered = Thing._by_fullname(fnames, data=True, return_dict=False)
baskets, new_quotas = filter_quotas(unfiltered)
if new_quotas is None:
pass
elif new_quotas == []:
g.hardcache.delete(key)
else:
g.hardcache.set(key, new_quotas, 86400 * 30)
return baskets
def canonical_email(self):
localpart, domain = str(self.email.lower()).split("@")
# a.s.d.f+something@gmail.com --> asdf@gmail.com
localpart.replace(".", "")
plus = localpart.find("+")
if plus > 0:
localpart = localpart[:plus]
return (localpart, domain)
def cromulent(self):
"""Return whether the user has validated their email address and
passes some rudimentary 'not evil' checks."""
if not self.email_verified:
return False
t = self.canonical_email()
if is_banned_email(*t):
return False
# Otherwise, congratulations; you're cromulent!
return True
def quota_limits(self, kind):
if kind != 'link':
raise NotImplementedError
if self.cromulent():
return dict(hour=3, day=10, week=50, month=150)
else:
return dict(hour=1, day=3, week=5, month=5)
def quota_full(self, kind):
limits = self.quota_limits(kind)
baskets = self.quota_baskets(kind)
if baskets is None:
return None
total = 0
filled_quota = None
for key in ('hour', 'day', 'week', 'month'):
total += len(baskets[key])
if total >= limits[key]:
filled_quota = key
return filled_quota
@classmethod
def cup_info_multi(cls, ids):
ids = [ int(i) for i in ids ]
# Is this dumb? Why call sgm() with miss_fn=None, rather than just
# calling g.hardcache.get_multi()?
return sgm(g.hardcache, ids, miss_fn=None, prefix="cup_info-")
@classmethod
def system_user(cls):
if not hasattr(g, "system_user"):
return None
try:
return cls._by_name(g.system_user)
except NotFound:
return None
class FakeAccount(Account):
_nodb = True
pref_no_profanity = True
def valid_cookie(cookie):
try:
uid, timestr, hash = cookie.split(',')
uid = int(uid)
except:
return (False, False)
if g.read_only_mode:
return (False, False)
try:
account = Account._byID(uid, True)
if account._deleted:
return (False, False)
except NotFound:
return (False, False)
if cookie == account.make_cookie(timestr, admin = False):
return (account, False)
elif cookie == account.make_cookie(timestr, admin = True):
return (account, True)
return (False, False)
def valid_feed(name, feedhash, path):
if name and feedhash and path:
from r2.lib.template_helpers import add_sr
path = add_sr(path)
try:
user = Account._by_name(name)
if (user.pref_private_feeds and
feedhash == make_feedhash(user, path)):
return user
except NotFound:
pass
def make_feedhash(user, path):
return sha.new("".join([user.name, user.password, g.FEEDSECRET])
).hexdigest()
def make_feedurl(user, path, ext = "rss"):
u = UrlParser(path)
u.update_query(user = user.name,
feed = make_feedhash(user, path))
u.set_extension(ext)
return u.unparse()
def valid_login(name, password):
try:
a = Account._by_name(name)
except NotFound:
return False
if not a._loaded: a._load()
return valid_password(a, password)
def valid_password(a, password):
try:
if a.password == passhash(a.name, password, ''):
#add a salt
a.password = passhash(a.name, password, True)
a._commit()
return a
else:
salt = a.password[:3]
if a.password == passhash(a.name, password, salt):
return a
except AttributeError, UnicodeEncodeError:
return False
def passhash(username, password, salt = ''):
if salt is True:
salt = randstr(3)
tohash = '%s%s %s' % (salt, username, password)
return salt + sha.new(tohash).hexdigest()
def change_password(user, newpassword):
user.password = passhash(user.name, newpassword, True)
user._commit()
return True
#TODO reset the cache
def register(name, password):
try:
a = Account._by_name(name)
raise AccountExists
except NotFound:
a = Account(name = name,
password = passhash(name, password, True))
# new accounts keep the profanity filter settings until opting out
a.pref_no_profanity = True
a._commit()
#clear the caches
Account._by_name(name, _update = True)
Account._by_name(name, allow_deleted = True, _update = True)
return a
class Friend(Relation(Account, Account)): pass
Account.__bases__ += (UserRel('friend', Friend, disable_reverse_ids_fn = True),)
class DeletedUser(FakeAccount):
@property
def name(self):
return '[deleted]'
@property
def _deleted(self):
return True
def _fullname(self):
raise NotImplementedError
def _id(self):
raise NotImplementedError
def __setattr__(self, attr, val):
if attr == '_deleted':
pass
else:
object.__setattr__(self, attr, val)