Skip to content
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

Pull content for moratorium banner from Contentful. #2125

Merged
merged 7 commits into from
Jul 13, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/initial_props.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
)

from .graphql import get_initial_session
from project import contentful

# This is changed by test suites to ensure that
# everything works okay when the server-side renderer fails
Expand Down Expand Up @@ -65,6 +66,7 @@ def create_initial_props_for_lambda(
"debug": settings.DEBUG,
"facebookAppId": settings.FACEBOOK_APP_ID,
"nycGeoSearchOrigin": settings.NYC_GEOSEARCH_ORIGIN,
"contentfulCommonStrings": contentful.get_common_strings(),
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be bad, if contentful ever goes down. We don't cache anything when there's a network error, so if cdn.contentful.com happens to timeout, all requests to our server could take an unusually long time, effectively resulting in a denial of service.

That said, contentful does use a CDN so this request should be pretty darn fast and timeouts should be extremely rare. Lots of businesses depend on Contentful's CDN to be responsive so we could always just give this a shot and revisit if anything ever goes wrong.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems reasonable

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool! My understanding is that the org site already has infrastructure to pull from contentful, so that one would be easier to change over. So that would just leave us with WOW still pulling from react-common, and presumably we could do something similar with WOW.

Right, though there are a few wrinkles--more details at JustFixNYC/who-owns-what#482!

"extraDevLinks": [
dict(
name="Mailchimp subscribe API documentation",
Expand Down
7 changes: 7 additions & 0 deletions frontend/lib/app-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { buildContextHocFactory } from "./util/context-util";
import { SiteChoice } from "../../common-data/site-choices";
import { SiteRoutes } from "./global-site-routes";
import { LocaleChoice } from "../../common-data/locale-choices";
import { ContentfulCommonStringsMapping } from "@justfixnyc/contentful-common-strings";

/** Metadata about forms submitted via legacy POST. */
export interface AppLegacyFormSubmission<FormInput = any, FormOutput = any> {
Expand Down Expand Up @@ -174,6 +175,12 @@ export interface AppServerInfo {
*/
nycGeoSearchOrigin: string;

/**
* Content from Contentful, shared across multiple JustFix properties,
* that have been passed to us from the back-end.
*/
contentfulCommonStrings: ContentfulCommonStringsMapping | null;

/**
* Additional links to other development-related links such as
* documentation, tooling, etc.
Expand Down
21 changes: 21 additions & 0 deletions frontend/lib/contentful.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Document } from "@contentful/rich-text-types";
import { ContentfulCommonStrings } from "@justfixnyc/contentful-common-strings";
import { useContext } from "react";

import { AppContext } from "./app-context";
import i18n from "./i18n";

/**
* Retrieve a common string (one shared across multiple JustFix properties)
* in the current locale from Contentful and return it.
*
* If the string doesn't exist or Contentful integration is disabled,
* `null` will be returned.
*/
export function useContentfulCommonString(key: string): Document | null {
const ccs = new ContentfulCommonStrings(
useContext(AppContext).server.contentfulCommonStrings || {}
);

return ccs.get(key, i18n.locale);
}
1 change: 1 addition & 0 deletions frontend/lib/tests/util.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export const FakeServerInfo: Readonly<AppServerInfo> = {
enableWipLocales: false,
facebookAppId: "",
nycGeoSearchOrigin: "https://myfunky.geosearch.nyc",
contentfulCommonStrings: null,
extraDevLinks: [],
};

Expand Down
77 changes: 45 additions & 32 deletions frontend/lib/ui/covid-banners.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@ import { CSSTransition } from "react-transition-group";
import JustfixRoutes from "../justfix-route-info";
import { useDebouncedValue } from "../util/use-debounced-value";
import { SupportedLocaleMap } from "../i18n";
import { CovidMoratoriumBanner } from "@justfixnyc/react-common";
import { li18n } from "../i18n-lingui";
import { Trans } from "@lingui/macro";
import { EnglishOutboundLink } from "./localized-outbound-link";
import {
documentToReactComponents,
RenderNode,
} from "@contentful/rich-text-react-renderer";
import { useContentfulCommonString } from "../contentful";
import { INLINES } from "@contentful/rich-text-types";

export const MORATORIUM_FAQ_URL: SupportedLocaleMap<string> = {
en:
Expand All @@ -27,54 +31,63 @@ const getRoutesWithMoratoriumBanner = () => [
JustfixRoutes.locale.home,
];

const RENDER_NODE: RenderNode = {
[INLINES.HYPERLINK]: (node, children) => (
<a rel="noreferrer noopener" target="_blank" href={node.data.uri}>
{children}
</a>
),
};

/**
* This banner is intended to show right below the navbar on certain pages and is a general
* overview of how JustFix.nyc is adapting to the COVID-19 crisis and Eviction Moratorium.
*/

const MoratoriumBanner = (props: { pathname?: string }) => {
const MoratoriumBanner: React.FC<{ pathname?: string }> = (props) => {
// This has to be debounced or it weirdly collides with our loading overlay
// that appears when pages need to load JS bundles and such, so we'll add a
// short debounce, which seems to obviate this issue.
const includeBanner = useDebouncedValue(
props.pathname && getRoutesWithMoratoriumBanner().includes(props.pathname),
10
);

const [isVisible, setVisibility] = useState(true);

const show = !!includeBanner && isVisible;
const document = useContentfulCommonString("covidMoratoriumBanner");

return (
<CSSTransition
in={show}
unmountOnExit
classNames="jf-slide-500px-200ms"
timeout={200}
>
<section
className={classnames(
"jf-moratorium-banner",
"hero",
"is-warning",
"is-small"
)}
document && (
<CSSTransition
in={show}
unmountOnExit
classNames="jf-slide-500px-200ms"
timeout={200}
>
<div className="hero-body">
<div className="container">
<SimpleProgressiveEnhancement>
<button
className="delete is-medium is-pulled-right"
onClick={() => setVisibility(false)}
/>
</SimpleProgressiveEnhancement>
<p>
<CovidMoratoriumBanner locale={li18n.language} />
</p>
<section
className={classnames(
"jf-moratorium-banner",
"hero",
"is-warning",
"is-small"
)}
>
<div className="hero-body">
<div className="container">
<SimpleProgressiveEnhancement>
<button
className="delete is-medium is-pulled-right"
onClick={() => setVisibility(false)}
/>
</SimpleProgressiveEnhancement>
{documentToReactComponents(document, {
renderNode: RENDER_NODE,
})}
</div>
</div>
</div>
</section>
</CSSTransition>
</section>
</CSSTransition>
)
);
};

Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,12 @@
"@babel/preset-env": "7.7.6",
"@babel/preset-typescript": "7.9.0",
"@babel/register": "7.7.4",
"@contentful/rich-text-react-renderer": "15.0.0",
"@contentful/rich-text-types": "15.0.0",
"@frontapp/plugin-sdk": "^1.0.1",
"@justfixnyc/contentful-common-strings": "^0.0.2",
"@justfixnyc/geosearch-requester": "0.4.0",
"@justfixnyc/react-aria-modal": "5.1.0",
"@justfixnyc/react-common": "0.0.12",
"@justfixnyc/util": "0.2.0",
"@lingui/cli": "2.9.1",
"@lingui/macro": "2.9.1",
Expand Down
74 changes: 74 additions & 0 deletions project/contentful.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import logging
from django.conf import settings
from django.core.cache import cache
from typing import Any, Dict, Optional
import requests


logger = logging.getLogger(__name__)

ORIGIN = "https://cdn.contentful.com"

CommonStrings = Dict[str, Any]


def _to_common_strings_map(raw: Any) -> CommonStrings:
"""
Converts the given raw Contentful API result and converts
it into a Contentful common strings mapping.

This is the Python version of the `toCommonStringsMap`
function from the following TypeScript code:

https://github.com/JustFixNYC/justfix-ts/blob/master/packages/contentful-common-strings/src/fetch-common-strings.ts
"""

result: CommonStrings = {}

for item in raw["items"]:
fields = item["fields"]
key = fields.get("id", {}).get("en")
value = fields.get("value")
if key and value:
result[key] = value

return result


def get_common_strings() -> Optional[CommonStrings]:
"""
Fetches Contentful common strings and returns them.

Caching is used to ensure that we don't trigger Contentful's rate
limiting or cause undue latency.

If Contentful integration is disabled, or if a network error
occurs and we don't have a cached value, returns `None`.
"""

if not (settings.CONTENTFUL_ACCESS_TOKEN and settings.CONTENTFUL_SPACE_ID):
# Contentful integration is disabled.
return None

cache_key = f"contentful_common_strings.{settings.CONTENTFUL_SPACE_ID}"

result = cache.get(cache_key)

if result is None:
try:
response = requests.get(
f"{ORIGIN}/spaces/{settings.CONTENTFUL_SPACE_ID}/entries",
{
"access_token": settings.CONTENTFUL_ACCESS_TOKEN,
"locale": "*",
"metadata.tags.sys.id[in]": settings.CONTENTFUL_COMMON_STRING_TAG,
},
timeout=settings.CONTENTFUL_TIMEOUT,
)
response.raise_for_status()
result = _to_common_strings_map(response.json())
cache.set(cache_key, result, settings.CONTENTFUL_CACHE_TIMEOUT)
except Exception:
logger.exception(f"Error while retrieving data from {ORIGIN}")

return result
4 changes: 4 additions & 0 deletions project/justfix_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,10 @@ class JustfixEnvironment(typed_environ.BaseEnvironment):
# The origin of the NYC GeoSearch API.
NYC_GEOSEARCH_ORIGIN: str = "https://geosearch.planninglabs.nyc"

CONTENTFUL_SPACE_ID: str = ""

CONTENTFUL_ACCESS_TOKEN: str = ""
Comment on lines +399 to +401
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should consider just using our actual space ID and access token as defaults here; because the access token is read-only and these credentials are intended for use in browser-side JS, they're effectively public (not secrets), and as such, just hard-coding them as defaults won't be a security hazard, and will also make it easier for developers to get up and running quickly.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm into that

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is kind of frustrating, I ended up adding the defaults to the WoW version of this PR, in JustFixNYC/who-owns-what#482, and immediately got this email:

image

ARGH. While I am pretty sure this isn't actually a security hole, I really, really wish it were possible to just tell Contentful to make a space publicly readable so we didn't have to go through this false alarm stuff.



class JustfixBuildPipelineDefaults(JustfixEnvironment):
"""
Expand Down
10 changes: 10 additions & 0 deletions project/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,16 @@

GEOCODING_TIMEOUT = 8

CONTENTFUL_SPACE_ID = env.CONTENTFUL_SPACE_ID

CONTENTFUL_ACCESS_TOKEN = env.CONTENTFUL_ACCESS_TOKEN

CONTENTFUL_COMMON_STRING_TAG = "common"

CONTENTFUL_TIMEOUT = 3

CONTENTFUL_CACHE_TIMEOUT = 5

GA_TRACKING_ID = env.GA_TRACKING_ID

GTM_CONTAINER_ID = env.GTM_CONTAINER_ID
Expand Down
2 changes: 2 additions & 0 deletions project/settings_pytest.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@

# Disable a bunch of third-party integrations by default.
GEOCODING_SEARCH_URL = ""
CONTENTFUL_SPACE_ID = ""
CONTENTFUL_ACCESS_TOKEN = ""
AIRTABLE_API_KEY = ""
AIRTABLE_URL = ""
SLACK_WEBHOOK_URL = ""
Expand Down
94 changes: 94 additions & 0 deletions project/tests/test_contentful.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import pytest
from django.core.cache import cache

from project.contentful import get_common_strings


ENTRIES_URL = "https://cdn.contentful.com/spaces/myspaceid/entries"

CONTENTFUL_DOC = {
"nodeType": "document",
"data": {},
"content": [
{
"nodeType": "paragraph",
"content": [
{
"nodeType": "text",
"value": "Hello!",
"marks": [],
"data": {},
},
],
},
],
}

RAW_ENTRIES_RESPONSE = {
"sys": {"type": "Array"},
"total": 1,
"skip": 0,
"limit": 100,
"items": [
{
"metadata": {"tags": [{"sys": {"type": "Link", "linkType": "Tag", "id": "common"}}]},
"sys": {
"space": {"sys": {"type": "Link", "linkType": "Space", "id": "markmr2gi204"}},
"id": "6JHYqWl0h2QWvObWQfNH4m",
"type": "Entry",
"createdAt": "2021-06-16T11:01:57.811Z",
"updatedAt": "2021-06-16T12:44:37.768Z",
"environment": {"sys": {"id": "master", "type": "Link", "linkType": "Environment"}},
"revision": 6,
"contentType": {"sys": {"type": "Link", "linkType": "ContentType", "id": "string"}},
},
"fields": {
"id": {"en": "covidMoratoriumBanner"},
"value": {
"en": CONTENTFUL_DOC,
},
},
}
],
}


@pytest.fixture
def enabled(settings):
settings.CONTENTFUL_ACCESS_TOKEN = "myaccesstoken"
settings.CONTENTFUL_SPACE_ID = "myspaceid"


class TestGetCommonStrings:
def setup(self):
cache.clear()

def test_it_returns_none_when_disabled(self):
assert get_common_strings() is None

def test_it_returns_none_when_enabled_but_err_occurs(self, enabled, requests_mock):
requests_mock.get(ENTRIES_URL, status_code=500)
assert get_common_strings() is None

def test_it_works(self, enabled, requests_mock):
requests_mock.get(ENTRIES_URL, json=RAW_ENTRIES_RESPONSE)
assert get_common_strings() == {"covidMoratoriumBanner": {"en": CONTENTFUL_DOC}}

def test_it_caches_result(self, enabled, requests_mock):
requests_mock.get(ENTRIES_URL, json=RAW_ENTRIES_RESPONSE)

strings = get_common_strings()
assert strings is not None

requests_mock.get(ENTRIES_URL, status_code=500)

assert get_common_strings() == strings

def test_it_does_not_cache_errors(self, enabled, requests_mock):
requests_mock.get(ENTRIES_URL, status_code=500)

assert get_common_strings() is None

requests_mock.get(ENTRIES_URL, json=RAW_ENTRIES_RESPONSE)

assert get_common_strings() is not None
Loading