-
Notifications
You must be signed in to change notification settings - Fork 370
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Implement attachment_api and serving of attachments #4541
Changes from 13 commits
da940ab
411bcfb
7ce7219
76d4378
7a13f16
1447a33
d5e7b19
42e7081
6f89aa5
c6fc5c9
f01848b
9153522
a45c837
99c5cc1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,94 @@ | ||||||||||||||||||||||||||||||||||||||
# Copyright 2024 Google Inc. | ||||||||||||||||||||||||||||||||||||||
# | ||||||||||||||||||||||||||||||||||||||
# Licensed under the Apache License, Version 2.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://www.apache.org/licenses/LICENSE-2.0 | ||||||||||||||||||||||||||||||||||||||
# | ||||||||||||||||||||||||||||||||||||||
# Unless required by applicable law or agreed to in writing, software | ||||||||||||||||||||||||||||||||||||||
# distributed under the License is distributed on an "AS IS" BASIS, | ||||||||||||||||||||||||||||||||||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||||||||||||||||||||||||||||||||||
# See the License for the specific language governing permissions and | ||||||||||||||||||||||||||||||||||||||
# limitations under the License. | ||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||
import logging | ||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||
from chromestatus_openapi.models import AddAttachmentResponse | ||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||
from framework import basehandlers | ||||||||||||||||||||||||||||||||||||||
from framework import permissions | ||||||||||||||||||||||||||||||||||||||
from internals import attachments | ||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||
class AttachmentsAPI(basehandlers.EntitiesAPIHandler): | ||||||||||||||||||||||||||||||||||||||
"""Features are the the main records that we track.""" | ||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||
@permissions.require_create_feature | ||||||||||||||||||||||||||||||||||||||
def do_post(self, **kwargs) -> dict[str, str]: | ||||||||||||||||||||||||||||||||||||||
"""Handle POST requests to create a single feature.""" | ||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also, I think you meant to adjust this comment too There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done. |
||||||||||||||||||||||||||||||||||||||
feature_id = kwargs.get('feature_id', None) | ||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||
# Validate the user has edit permissions and redirect if needed. | ||||||||||||||||||||||||||||||||||||||
redirect_resp = permissions.validate_feature_edit_permission( | ||||||||||||||||||||||||||||||||||||||
self, feature_id) | ||||||||||||||||||||||||||||||||||||||
if redirect_resp: | ||||||||||||||||||||||||||||||||||||||
self.abort(403, msg='User lacks permission to edit') | ||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||
files = kwargs.get('mock_files', self.request.files) | ||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should not have this logic that looks for mock_files in the keywords in the live code. Instead, we should adjust the tests. More about this in the test file.
Suggested change
|
||||||||||||||||||||||||||||||||||||||
logging.info('files are %r', files) | ||||||||||||||||||||||||||||||||||||||
if 'uploaded-file' not in files: | ||||||||||||||||||||||||||||||||||||||
self.abort(400, msg='Unexpected file upload') | ||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||
file = files['uploaded-file'] | ||||||||||||||||||||||||||||||||||||||
if file.filename == '': | ||||||||||||||||||||||||||||||||||||||
self.abort(400, msg='No file was selected') | ||||||||||||||||||||||||||||||||||||||
content = file.read() | ||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||
attach = attachments.store_attachment(feature_id, content, file.mimetype) | ||||||||||||||||||||||||||||||||||||||
url = attachments.get_attachment_url(attach) | ||||||||||||||||||||||||||||||||||||||
return AddAttachmentResponse.from_dict({'attachment_url': url}).to_dict() | ||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||
class AttachmentServing(basehandlers.FlaskHandler): | ||||||||||||||||||||||||||||||||||||||
"""Serve an attachment.""" | ||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||
def maybe_redirect(self, attachment: attachments.Attachment, is_thumb: bool): | ||||||||||||||||||||||||||||||||||||||
"""If needed, redirect to a safe domain.""" | ||||||||||||||||||||||||||||||||||||||
logging.info('url is: %r ', self.request.url) | ||||||||||||||||||||||||||||||||||||||
attach_url = attachments.get_attachment_url(attachment) | ||||||||||||||||||||||||||||||||||||||
thumb_url = attach_url + '/thumbnail' | ||||||||||||||||||||||||||||||||||||||
logging.info('attach_url is: %r ', attach_url) | ||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||
if self.request.url in (attach_url, thumb_url): | ||||||||||||||||||||||||||||||||||||||
return None | ||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||
if is_thumb: | ||||||||||||||||||||||||||||||||||||||
return self.redirect(thumb_url) | ||||||||||||||||||||||||||||||||||||||
else: | ||||||||||||||||||||||||||||||||||||||
return self.redirect(attach_url) | ||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||
def get_template_data(self, **kwargs): | ||||||||||||||||||||||||||||||||||||||
"""Serve the attachment data, or redirect to a cookieless domain.""" | ||||||||||||||||||||||||||||||||||||||
feature_id = kwargs.get('feature_id') | ||||||||||||||||||||||||||||||||||||||
is_thumb = 'thumbnail' in kwargs | ||||||||||||||||||||||||||||||||||||||
attachment_id = kwargs.get('attachment_id') | ||||||||||||||||||||||||||||||||||||||
attachment = attachments.get_attachment(feature_id, attachment_id) | ||||||||||||||||||||||||||||||||||||||
if not attachment: | ||||||||||||||||||||||||||||||||||||||
self.abort(404, msg='Attachment not found') | ||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||
redirect_response = self.maybe_redirect(attachment, is_thumb) | ||||||||||||||||||||||||||||||||||||||
if redirect_response: | ||||||||||||||||||||||||||||||||||||||
return redirect_response | ||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||
if is_thumb and attachment.thumbnail: | ||||||||||||||||||||||||||||||||||||||
content = attachment.thumbnail | ||||||||||||||||||||||||||||||||||||||
headers = self.get_headers() | ||||||||||||||||||||||||||||||||||||||
headers['Content-Type'] = 'image/png' | ||||||||||||||||||||||||||||||||||||||
else: | ||||||||||||||||||||||||||||||||||||||
content = attachment.content | ||||||||||||||||||||||||||||||||||||||
headers = self.get_headers() | ||||||||||||||||||||||||||||||||||||||
headers['Content-Type'] = attachment.mime_type | ||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can raise
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done. |
||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||
return content, headers |
Original file line number | Diff line number | Diff line change | ||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,175 @@ | ||||||||||||||||
# Copyright 2024 Google Inc. | ||||||||||||||||
# | ||||||||||||||||
# Licensed under the Apache License, Version 2.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://www.apache.org/licenses/LICENSE-2.0 | ||||||||||||||||
# | ||||||||||||||||
# Unless required by applicable law or agreed to in writing, software | ||||||||||||||||
# distributed under the License is distributed on an "AS IS" BASIS, | ||||||||||||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||||||||||||
# See the License for the specific language governing permissions and | ||||||||||||||||
# limitations under the License. | ||||||||||||||||
|
||||||||||||||||
import testing_config # Must be imported before the module under test. | ||||||||||||||||
|
||||||||||||||||
import flask | ||||||||||||||||
from datetime import datetime | ||||||||||||||||
from unittest import mock | ||||||||||||||||
from google.cloud import ndb # type: ignore | ||||||||||||||||
import werkzeug.exceptions | ||||||||||||||||
|
||||||||||||||||
import settings | ||||||||||||||||
from api import attachments_api | ||||||||||||||||
from internals.core_enums import * | ||||||||||||||||
from internals.core_models import FeatureEntry | ||||||||||||||||
from internals import attachments | ||||||||||||||||
|
||||||||||||||||
test_app = flask.Flask(__name__) | ||||||||||||||||
test_app.secret_key ='test' | ||||||||||||||||
|
||||||||||||||||
|
||||||||||||||||
class AttachmentsAPITest(testing_config.CustomTestCase): | ||||||||||||||||
|
||||||||||||||||
def setUp(self): | ||||||||||||||||
self.feature = FeatureEntry( | ||||||||||||||||
name='feat', summary='sum', category=1, | ||||||||||||||||
owner_emails=['[email protected]'], | ||||||||||||||||
impl_status_chrome=ENABLED_BY_DEFAULT) | ||||||||||||||||
self.feature.put() | ||||||||||||||||
|
||||||||||||||||
self.feature_id = self.feature.key.integer_id() | ||||||||||||||||
self.request_path = f'/api/v0/features/{self.feature_id}/attachments' | ||||||||||||||||
self.handler = attachments_api.AttachmentsAPI() | ||||||||||||||||
|
||||||||||||||||
def tearDown(self): | ||||||||||||||||
testing_config.sign_out() | ||||||||||||||||
kinds: list[ndb.Model] = [FeatureEntry, attachments.Attachment] | ||||||||||||||||
for kind in kinds: | ||||||||||||||||
for entity in kind.query(): | ||||||||||||||||
entity.key.delete() | ||||||||||||||||
|
||||||||||||||||
def test_do_post__anon(self): | ||||||||||||||||
"""Anon users cannot add attachments.""" | ||||||||||||||||
testing_config.sign_out() | ||||||||||||||||
with test_app.test_request_context(self.request_path): | ||||||||||||||||
with self.assertRaises(werkzeug.exceptions.Forbidden): | ||||||||||||||||
self.handler.do_post(feature_id=self.feature_id) | ||||||||||||||||
|
||||||||||||||||
def test_do_post__unregistered(self): | ||||||||||||||||
"""Users who cannot create features cannot add attachments.""" | ||||||||||||||||
testing_config.sign_in('[email protected]', 111) | ||||||||||||||||
with test_app.test_request_context(self.request_path): | ||||||||||||||||
with self.assertRaises(werkzeug.exceptions.Forbidden): | ||||||||||||||||
self.handler.do_post(feature_id=self.feature_id) | ||||||||||||||||
|
||||||||||||||||
def test_do_post__noneditor(self): | ||||||||||||||||
"""Users who cannot edit this particular feature cannot add attachments.""" | ||||||||||||||||
testing_config.sign_in('[email protected]', 111) | ||||||||||||||||
with test_app.test_request_context(self.request_path): | ||||||||||||||||
with self.assertRaises(werkzeug.exceptions.Forbidden): | ||||||||||||||||
self.handler.do_post(feature_id=self.feature_id) | ||||||||||||||||
|
||||||||||||||||
def test_do_post__no_files(self): | ||||||||||||||||
"""Reject requests that have no attachments.""" | ||||||||||||||||
testing_config.sign_in('[email protected]', 111) | ||||||||||||||||
with test_app.test_request_context(self.request_path): | ||||||||||||||||
with self.assertRaises(werkzeug.exceptions.BadRequest): | ||||||||||||||||
self.handler.do_post(feature_id=self.feature_id) | ||||||||||||||||
|
||||||||||||||||
def test_do_post__empty_file(self): | ||||||||||||||||
"""Reject requests where the user did not upload.""" | ||||||||||||||||
testing_config.sign_in('[email protected]', 111) | ||||||||||||||||
body = '' | ||||||||||||||||
with test_app.test_request_context(self.request_path, data=body): | ||||||||||||||||
with self.assertRaises(werkzeug.exceptions.BadRequest): | ||||||||||||||||
self.handler.do_post(feature_id=self.feature_id) | ||||||||||||||||
|
||||||||||||||||
def test_do_post__valid_file(self): | ||||||||||||||||
"""With a valid user and valid request, we store the attachment.""" | ||||||||||||||||
testing_config.sign_in('[email protected]', 111) | ||||||||||||||||
mock_files = {'uploaded-file': testing_config.Blank( | ||||||||||||||||
filename='hello_attach.txt', | ||||||||||||||||
read=lambda: b'hello attachments!', | ||||||||||||||||
mimetype='text/plain')} | ||||||||||||||||
with test_app.test_request_context(self.request_path): | ||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for that suggestion. I had spent half the day struggling with passing in a complete POST body as a binary string. |
||||||||||||||||
actual = self.handler.do_post( | ||||||||||||||||
feature_id=self.feature_id, mock_files=mock_files) | ||||||||||||||||
|
||||||||||||||||
attachment_id = int(actual['attachment_url'].split('/')[-1]) | ||||||||||||||||
attachment = attachments.Attachment.get_by_id(attachment_id) | ||||||||||||||||
expected_url = attachments.get_attachment_url(attachment) | ||||||||||||||||
self.assertEqual(actual['attachment_url'], expected_url) | ||||||||||||||||
|
||||||||||||||||
|
||||||||||||||||
class AttachmentServingTest(testing_config.CustomTestCase): | ||||||||||||||||
|
||||||||||||||||
def setUp(self): | ||||||||||||||||
self.feature = FeatureEntry( | ||||||||||||||||
name='feat', summary='sum', category=1, | ||||||||||||||||
owner_emails=['[email protected]'], | ||||||||||||||||
impl_status_chrome=ENABLED_BY_DEFAULT) | ||||||||||||||||
self.feature.put() | ||||||||||||||||
self.feature_id = self.feature.key.integer_id() | ||||||||||||||||
|
||||||||||||||||
self.content = b'Are you being served?' | ||||||||||||||||
self.attachment = attachments.Attachment( | ||||||||||||||||
feature_id=self.feature_id, | ||||||||||||||||
content=self.content, | ||||||||||||||||
mime_type='text/plain') | ||||||||||||||||
self.attachment.put() | ||||||||||||||||
self.attachment_id = self.attachment.key.integer_id() | ||||||||||||||||
|
||||||||||||||||
self.request_path = ( | ||||||||||||||||
f'/feature/{self.feature_id}/attachment/{self.attachment_id}') | ||||||||||||||||
self.handler = attachments_api.AttachmentServing() | ||||||||||||||||
|
||||||||||||||||
def tearDown(self): | ||||||||||||||||
testing_config.sign_out() | ||||||||||||||||
kinds: list[ndb.Model] = [FeatureEntry, attachments.Attachment] | ||||||||||||||||
for kind in kinds: | ||||||||||||||||
for entity in kind.query(): | ||||||||||||||||
entity.key.delete() | ||||||||||||||||
|
||||||||||||||||
def test_maybe_redirect__expected_url(self): | ||||||||||||||||
"""Requesting an attachment from the canonical URL returns None.""" | ||||||||||||||||
# self.request_path is the same as the canonical URL. | ||||||||||||||||
base = settings.SITE_URL | ||||||||||||||||
with test_app.test_request_context(self.request_path, base_url=base): | ||||||||||||||||
actual = self.handler.maybe_redirect(self.attachment, False) | ||||||||||||||||
self.assertIsNone(actual) | ||||||||||||||||
|
||||||||||||||||
with test_app.test_request_context( | ||||||||||||||||
self.request_path + '/thumbnail', base_url=base): | ||||||||||||||||
actual = self.handler.maybe_redirect(self.attachment, True) | ||||||||||||||||
self.assertIsNone(actual) | ||||||||||||||||
|
||||||||||||||||
def test_maybe_redirect__alt_base(self): | ||||||||||||||||
"""Requesting an attachment from a different URL gives a redirect.""" | ||||||||||||||||
alt_base = 'https://chromestatus.com' | ||||||||||||||||
with test_app.test_request_context(self.request_path, base_url=alt_base): | ||||||||||||||||
actual = self.handler.maybe_redirect(self.attachment, False) | ||||||||||||||||
self.assertEqual(actual.status_code, 302) | ||||||||||||||||
self.assertEqual( | ||||||||||||||||
actual.location, attachments.get_attachment_url(self.attachment)) | ||||||||||||||||
|
||||||||||||||||
def test_get_template_data__not_found(self): | ||||||||||||||||
"""Requesting with a wrong ID gives a 404.""" | ||||||||||||||||
with test_app.test_request_context(self.request_path): | ||||||||||||||||
with self.assertRaises(werkzeug.exceptions.NotFound): | ||||||||||||||||
self.handler.get_template_data( | ||||||||||||||||
feature_id=self.feature_id, attachment_id=self.attachment_id + 1) | ||||||||||||||||
with self.assertRaises(werkzeug.exceptions.NotFound): | ||||||||||||||||
self.handler.get_template_data( | ||||||||||||||||
feature_id=self.feature_id + 1, attachment_id=self.attachment_id) | ||||||||||||||||
|
||||||||||||||||
def test_get_template_data__found(self): | ||||||||||||||||
"""We can fetch an attachment.""" | ||||||||||||||||
base = settings.SITE_URL | ||||||||||||||||
with test_app.test_request_context(self.request_path, base_url=base): | ||||||||||||||||
content, headers = self.handler.get_template_data( | ||||||||||||||||
feature_id=self.feature_id, attachment_id=self.attachment_id) | ||||||||||||||||
|
||||||||||||||||
self.assertEqual(content, self.content) | ||||||||||||||||
self.assertEqual(headers['Content-Type'], 'text/plain') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: I think this comment was a copy-paste from features_api
chromium-dashboard/api/features_api.py
Line 53 in 92c888d
Instead, you may want to mention how the attachments relate to features
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed.