Generate opengraph images automatically, update configuration defaults with new logos

This commit is contained in:
checktheroads 2020-07-04 14:57:47 -07:00
parent 270ffecebb
commit d0eaffb74e
7 changed files with 297 additions and 61 deletions

View file

@ -6,8 +6,7 @@ from typing import Optional
from pathlib import Path
# Third Party
import PIL.Image as PilImage
from pydantic import FilePath, StrictInt, root_validator
from pydantic import FilePath, StrictInt, validator
# Project
from hyperglass.models import HyperglassModel
@ -21,11 +20,11 @@ class OpenGraph(HyperglassModel):
width: Optional[StrictInt]
height: Optional[StrictInt]
image: FilePath = DEFAULT_IMAGES / "hyperglass-opengraph.png"
image: FilePath = DEFAULT_IMAGES / "hyperglass-opengraph.jpg"
@root_validator
def validate_opengraph(cls, values):
"""Set default opengraph image location.
@validator("image")
def validate_opengraph(cls, value):
"""Ensure the opengraph image is a supported format.
Arguments:
value {FilePath} -- Path to opengraph image file.
@ -34,41 +33,11 @@ class OpenGraph(HyperglassModel):
{Path} -- Opengraph image file path object
"""
supported_extensions = (".jpg", ".jpeg", ".png")
if (
values["image"] is not None
and values["image"].suffix not in supported_extensions
):
if value is not None and value.suffix not in supported_extensions:
raise ValueError(
"OpenGraph image must be one of {e}".format(
e=", ".join(supported_extensions)
)
)
with PilImage.open(values["image"]) as img:
width, height = img.size
if values["width"] is None:
values["width"] = width
if values["height"] is None:
values["height"] = height
return values
class Config:
"""Pydantic model configuration."""
title = "OpenGraph"
description = "OpenGraph configuration parameters"
fields = {
"width": {
"title": "Width",
"description": "Width of OpenGraph image. If unset, the width will be automatically derived by reading the image file.",
},
"height": {
"title": "Height",
"description": "Height of OpenGraph image. If unset, the height will be automatically derived by reading the image file.",
},
"image": {
"title": "Image File",
"description": "Valid path to a JPG or PNG file to use as the OpenGraph image.",
},
}
return value

View file

@ -92,10 +92,10 @@ class Greeting(HyperglassModel):
class Logo(HyperglassModel):
"""Validation model for logo configuration."""
light: FilePath = DEFAULT_IMAGES / "hyperglass-light.png"
dark: FilePath = DEFAULT_IMAGES / "hyperglass-dark.png"
favicon: FilePath = DEFAULT_IMAGES / "icon.svg"
width: Optional[Union[StrictInt, constr(regex=r"^([1-9][0-9]?|100)\%$")]] = "80%"
light: FilePath = DEFAULT_IMAGES / "hyperglass-light.svg"
dark: FilePath = DEFAULT_IMAGES / "hyperglass-dark.svg"
favicon: FilePath = DEFAULT_IMAGES / "hyperglass-icon.svg"
width: Optional[Union[StrictInt, constr(regex=r"^([1-9][0-9]?|100)\%$")]] = "75%"
height: Optional[Union[StrictInt, constr(regex=r"^([1-9][0-9]?|100)\%$")]]
@ -144,8 +144,8 @@ class Text(HyperglassModel):
class ThemeColors(HyperglassModel):
"""Validation model for theme colors."""
black: Color = "#262626"
white: Color = "#f7f7f7"
black: Color = "#121212"
white: Color = "#f5f6f7"
gray: Color = "#c1c7cc"
red: Color = "#d84b4b"
orange: Color = "#ff6b35"

View file

@ -0,0 +1,194 @@
# cache:
# database: 1
# host: localhost
# port: 6379
# show_text: true
# timeout: 120
# cors_origins: []
# debug: true
# developer_mode: true
# docs:
# base_url: https://lg.example.net
# communities:
# description: List of BGP communities.
# summary: BGP Communities List
# title: BGP Communities
# description: ''
# devices:
# description:
# List of all devices/locations with associated identifiers, display
# names, networks, & VRFs.
# summary: Devices List
# title: Devices
# enable: true
# mode: redoc
# openapi_uri: /openapi.json
# queries:
# description: List of supported query types.
# summary: Query Types
# title: Supported Queries
# query:
# description: Request a query response per-location.
# summary: Query the Looking Glass
# title: Submit Query
# title: '{site_title} API Documentation'
# uri: /api/docs
# listen_address: 0.0.0.0
# listen_port: 8001
# logging:
# directory: /tmp
# format: text
# http: null
# max_size: 50000000
# syslog: null
# messages:
# acl_denied: '{target} is a member of {denied_network}, which is not allowed.'
# acl_not_allowed: '{target} is not allowed.'
# authentication_error: Authentication error occurred.
# connection_error: 'Error connecting to {device_name}: {error}'
# feature_not_enabled: '{feature} is not enabled.'
# general: Something went wrong.
# invalid_field: '{input} is an invalid {field}.'
# invalid_input: '{target} is not a valid {query_type} target.'
# no_input: '{field} must be specified.'
# no_output: The query completed, but no matching results were found.
# no_response: No response.
# parsing_error: An error occurred while parsing the query output.
# request_timeout: Request timed out.
# vrf_not_associated: VRF {vrf_name} is not associated with {device_name}.
# vrf_not_found: VRF {vrf_name} is not defined.
# netmiko_delay_factor: 0.1
# org_name: Beloved Hyperglass User
# primary_asn: '65001'
# queries:
# bgp_aspath:
# display_name: BGP AS Path
# enable: true
# pattern:
# asdot: ^(\^|^\_)((\d+\.\d+)\_|(\d+\.\d+)\$|(\d+\.\d+)\(\_\.\+\_\))+$
# asplain: .*
# mode: asplain
# bgp_community:
# communities:
# - community: 14525:5001
# description: Phoenix, AZ Metro Aggregate Routes
# display_name: 14525:5001
# display_name: BGP Community
# enable: true
# mode: select
# pattern:
# decimal: ^[0-9]{1,10}$
# extended_as: ^([0-9]{0,5})\:([0-9]{1,5})$
# large: ^([0-9]{1,10})\:([0-9]{1,10})\:[0-9]{1,10}$
# bgp_route:
# display_name: BGP Route
# enable: true
# ping:
# display_name: Ping
# enable: true
# traceroute:
# display_name: Traceroute
# enable: true
# request_timeout: 90
# site_description: Beloved Hyperglass User Network Looking Glass
# site_keywords:
# - Beloved Hyperglass User
# - bgp
# - communities
# - community
# - dev hyperglass
# - hyperglass
# - internet service provider
# - ip
# - ipv4
# - ipv6
# - isp
# - lg
# - looking glass
# - network
# - peer
# - peering
# - routing
# - transit
# site_title: dev hyperglass
# structured:
# communities:
# items:
# - 14525:\d{4}
# mode: permit
# rpki:
# mode: external
# web:
# credit:
# enable: true
# dns_provider:
# name: cloudflare
# url: https://cloudflare-dns.com/dns-query
# external_link:
# enable: true
# title: PeeringDB
# url: https://www.peeringdb.com/asn/{primary_asn}
# greeting:
# button: Continue
# enable: false
# file: null
# required: false
# title: Welcome
# help_menu:
# enable: true
# file: null
# title: Help
# logo:
# dark: /Users/ml/dev/hyperglass/hyperglass/images/hyperglass-dark.svg
# favicon: /Users/ml/dev/hyperglass/hyperglass/images/hyperglass-icon.svg
# height: null
# light: /Users/ml/dev/hyperglass/hyperglass/images/hyperglass-light.svg
# width: 75%
# opengraph:
# height: null
# image: /Users/ml/dev/hyperglass/hyperglass/images/hyperglass-opengraph.jpg
# width: null
# terms:
# enable: true
# file: null
# title: Terms
# text:
# cache_icon: Cached from {time} UTC
# cache_prefix: 'Results cached for '
# complete_time: Completed in {seconds}
# fqdn_tooltip: Use {protocol}
# query_location: Location
# query_target: Target
# query_type: Query Type
# query_vrf: Routing Table
# rpki_invalid: Invalid
# rpki_unknown: No ROAs Exist
# rpki_unverified: Not Verified
# rpki_valid: Valid
# subtitle: really really really really really long subtitle
# title: hyperglass
# title_mode: logo_subtitle
# theme:
# colors:
# black: '#121212'
# blue: '#314cb6'
# cyan: '#118ab2'
# danger: '#d84b4b'
# error: '#ff6b35'
# gray: '#c1c7cc'
# green: '#35b246'
# orange: '#ff6b35'
# pink: '#f2607d'
# primary: '#118ab2'
# purple: '#8d30b5'
# red: '#d84b4b'
# secondary: '#314cb6'
# success: '#35b246'
# teal: '#35b299'
# warning: '#edae49'
# white: '#f5f6f7'
# yellow: '#edae49'
# default_color_mode: null
# fonts:
# body: Nunito
# mono: Fira Code

View file

@ -28,13 +28,10 @@ const Meta = () => {
"isp"
];
const language = config?.language ?? "en";
const ogImage = config?.web.opengraph.image ?? null;
const ogImageHeight = config?.web.opengraph.height ?? null;
const ogImageWidth = config?.web.opengraph.width ?? null;
const primaryFont = googleFontUrl(theme.fonts.body);
const monoFont = googleFontUrl(theme.fonts.mono);
useEffect(() => {
if (typeof window !== undefined && location === {}) {
if (typeof window !== "undefined" && location === {}) {
setLocation(window.location);
}
}, [location]);
@ -48,11 +45,8 @@ const Meta = () => {
<meta name="url" content={location.href} />
<meta name="og:title" content={title} />
<meta name="og:url" content={location.href} />
<meta name="og:image" content={ogImage} />
<meta name="og:description" content={description} />
<meta property="og:image:alt" content={siteName} />
<meta property="og:image:width" content={ogImageWidth} />
<meta property="og:image:height" content={ogImageHeight} />
<link href={primaryFont} rel="stylesheet" />
<link href={monoFont} rel="stylesheet" />
</Head>

View file

@ -1,4 +1,5 @@
import React from "react";
import * as React from "react";
import Head from "next/head";
// import { useRouter } from "next/router";
import { HyperglassProvider } from "~/components/HyperglassProvider";
// import Error from "./_error";
@ -11,9 +12,20 @@ const Hyperglass = ({ Component, pageProps }) => {
// return <Error msg="/structured" statusCode={404} />;
// }
return (
<HyperglassProvider config={config}>
<Component {...pageProps} />
</HyperglassProvider>
<>
<Head>
<title>hyperglass</title>
<meta httpEquiv="Content-Type" content="text/html" />
<meta charSet="UTF-8" />
<meta name="og:type" content="website" />
<meta name="og:image" content="/images/opengraph.jpg" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
</Head>
<HyperglassProvider config={config}>
<Component {...pageProps} />
</HyperglassProvider>
</>
);
};

View file

@ -13,11 +13,12 @@ class MyDocument extends Document {
<Head>
<link rel="dns-prefetch" href="//fonts.googleapis.com" />
<link rel="dns-prefetch" href="//fonts.gstatic.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin="true"
/>
<link rel="preconnect" href="https://www.google-analytics.com" />
<meta charSet="UTF-8" />
<meta httpEquiv="Content-Type" content="text/html" />
<meta name="og:type" content="website" />
</Head>
<body>
<script src="noflash.js" />

View file

@ -1,6 +1,7 @@
"""Utility functions."""
# Standard Library
import math
import shutil
from queue import Queue
from typing import Iterable
@ -413,6 +414,58 @@ async def read_package_json():
return package_json
def generate_opengraph(
image_path: Path,
max_width: int,
max_height: int,
target_path: Path,
background_color: str,
):
"""Generate an OpenGraph compliant image."""
from PIL import Image
def center_point(background: Image, foreground: Image):
"""Generate a tuple of center points for PIL."""
bg_x, bg_y = background.size[0:2]
fg_x, fg_y = foreground.size[0:2]
x1 = math.floor((bg_x / 2) - (fg_x / 2))
y1 = math.floor((bg_y / 2) - (fg_y / 2))
x2 = math.floor((bg_x / 2) + (fg_x / 2))
y2 = math.floor((bg_y / 2) + (fg_y / 2))
return (x1, y1, x2, y2)
# Convert image to JPEG format with static name "opengraph.jpg"
dst_path = target_path / "opengraph.jpg"
# Copy the original image to the target path
copied = shutil.copy2(image_path, target_path)
with Image.open(copied) as src:
# Only resize the image if it needs to be resized
if src.size[0] != max_width or src.size[1] != max_height:
# Resize image while maintaining aspect ratio
src.thumbnail((max_width, max_height))
# Only impose a background image if the original image has
# alpha/transparency channels
if src.mode in ("RGBA", "LA"):
background = Image.new("RGB", (max_width, max_height), background_color)
background.paste(src, box=center_point(background, src))
dst = background
else:
dst = src
# Save new image to derived target path
dst.save(dst_path)
if not dst_path.exists():
raise RuntimeError(f"Unable to save resized image to {str(dst_path)}")
return True
class FileCopy(Thread):
"""Custom thread for copyfiles() function."""
@ -476,7 +529,7 @@ def copyfiles(src_files: Iterable[Path], dst_files: Iterable[Path]):
return True
async def migrate_images(app_path, params):
async def migrate_images(app_path: Path, params: dict):
"""Migrate images from source code to install directory."""
images_dir = app_path / "static" / "images"
favicon_dir = images_dir / "favicons"
@ -493,7 +546,12 @@ async def migrate_images(app_path, params):
async def build_frontend( # noqa: C901
dev_mode, dev_url, prod_url, params, app_path, force=False
dev_mode: bool,
dev_url: str,
prod_url: str,
params: dict,
app_path: Path,
force: bool = False,
):
"""Perform full frontend UI build process.
@ -616,6 +674,14 @@ async def build_frontend( # noqa: C901
await migrate_images(app_path, params)
generate_opengraph(
Path(params["web"]["opengraph"]["image"]),
1200,
630,
app_path / "static" / "images",
params["web"]["theme"]["colors"]["black"],
)
except Exception as e:
raise RuntimeError(str(e)) from None