2 # This file is part of my.gpodder.org.
4 # my.gpodder.org is free software: you can redistribute it and/or modify it
5 # under the terms of the GNU Affero General Public License as published by
6 # the Free Software Foundation, either version 3 of the License, or (at your
7 # option) any later version.
9 # my.gpodder.org is distributed in the hope that it will be useful, but
10 # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
11 # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
12 # License for more details.
14 # You should have received a copy of the GNU Affero General Public License
15 # along with my.gpodder.org. If not, see <http://www.gnu.org/licenses/>.
18 from datetime
import datetime
20 from django
.http
import HttpResponseBadRequest
, HttpResponseNotFound
21 from django
.views
.decorators
.csrf
import csrf_exempt
22 from django
.views
.decorators
.cache
import never_cache
23 from django
.utils
.decorators
import method_decorator
24 from django
.views
.generic
.base
import View
26 from mygpo
.api
.httpresponse
import JsonResponse
27 from mygpo
.api
.backend
import get_device
, BulkSubscribe
28 from mygpo
.utils
import get_timestamp
, \
29 parse_request_body
, normalize_feed_url
, intersect
30 from mygpo
.decorators
import cors_origin
31 from mygpo
.users
.models
import DeviceDoesNotExist
32 from mygpo
.core
.json
import JSONDecodeError
33 from mygpo
.api
.basic_auth
import require_valid_user
, check_username
34 from mygpo
.db
.couchdb
import BulkException
38 logger
= logging
.getLogger(__name__
)
41 class RequestException(Exception):
42 """ Raised if the request is malfored or otherwise invalid """
47 @method_decorator(csrf_exempt
)
48 @method_decorator(require_valid_user
)
49 @method_decorator(check_username
)
50 @method_decorator(never_cache
)
51 @method_decorator(cors_origin())
52 def dispatch(self
, *args
, **kwargs
):
53 """ Dispatches request and does generic error handling """
55 return super(APIView
, self
).dispatch(*args
, **kwargs
)
57 except DeviceDoesNotExist
as e
:
58 return HttpResponseNotFound(str(e
))
60 except RequestException
as e
:
61 return HttpResponseBadRequest(str(e
))
63 def parsed_body(self
, request
):
64 """ Returns the object parsed from the JSON request body """
67 raise RequestException('POST data must not be empty')
70 # TODO: implementation of parse_request_body can be moved here
71 # after all views using it have been refactored
72 return parse_request_body(request
)
73 except (JSONDecodeError
, UnicodeDecodeError, ValueError) as e
:
74 msg
= u
'Could not decode request body for user {}: {}'.format(
75 username
, request
.body
.decode('ascii', errors
='replace'))
76 logger
.warn(msg
, exc_info
=True)
77 raise RequestException(msg
)
79 def get_since(self
, request
):
80 """ Returns parsed "since" GET parameter """
81 since_
= request
.GET
.get('since', None)
84 raise RequestException("parameter 'since' missing")
87 since
= datetime
.fromtimestamp(int(since_
))
89 raise RequestException("'since' is not a valid timestamp")
92 raise RequestException("'since' must be a non-negative number")
97 class SubscriptionsAPI(APIView
):
98 """ API for sending and retrieving podcast subscription updates """
100 def get(self
, request
, version
, username
, device_uid
):
101 """ Client retrieves subscription updates """
102 now
= datetime
.utcnow()
103 device
= request
.user
.get_device_by_uid(device_uid
)
104 since
= self
.get_since(request
)
105 add
, rem
, until
= self
.get_changes(device
, since
, now
)
106 return JsonResponse({
112 def post(self
, request
, version
, username
, device_uid
):
113 """ Client sends subscription updates """
114 now
= get_timestamp(datetime
.utcnow())
116 d
= get_device(request
.user
, device_uid
,
117 request
.META
.get('HTTP_USER_AGENT', ''))
119 actions
= self
.parsed_body(request
)
121 add
= filter(None, actions
.get('add', []))
122 rem
= filter(None, actions
.get('remove', []))
124 update_urls
= self
.update_subscriptions(request
.user
, d
, add
, rem
)
126 return JsonResponse({
128 'update_urls': update_urls
,
131 def update_subscriptions(self
, user
, device
, add
, remove
):
133 conflicts
= intersect(add
, remove
)
135 msg
= "can not add and remove '{}' at the same time".format(
137 raise RequestException(msg
)
139 add_s
= map(normalize_feed_url
, add
)
140 rem_s
= map(normalize_feed_url
, remove
)
142 assert len(add
) == len(add_s
) and len(remove
) == len(rem_s
)
144 pairs
= zip(add
+ remove
, add_s
+ rem_s
)
145 updated_urls
= filter(lambda (a
, b
): a
!= b
, pairs
)
147 add_s
= filter(None, add_s
)
148 rem_s
= filter(None, rem_s
)
150 # If two different URLs (in add and remove) have
151 # been sanitized to the same, we ignore the removal
152 rem_s
= filter(lambda x
: x
not in add_s
, rem_s
)
154 subscriber
= BulkSubscribe(user
, device
)
157 subscriber
.add_action(a
, 'subscribe')
160 subscriber
.add_action(r
, 'unsubscribe')
164 except BulkException
as be
:
165 for err
in be
.errors
:
166 msg
= 'Advanced API: {user}: Updating subscription for ' \
167 '{podcast} on {device} failed: {err} {reason}'.format(
168 user
=user
.username
, podcast
=err
.doc
,
169 device
=device
.uid
, err
=err
.error
, reason
=err
.reason
)
174 def get_changes(self
, device
, since
, until
):
175 """ Returns subscription changes for the given device """
176 add_urls
, rem_urls
= device
.get_subscription_changes(since
, until
)
177 until_
= get_timestamp(until
)
178 return (add_urls
, rem_urls
, until_
)