From 97a73f0a327f42a6f29116e1d40e4c454ff143d6 Mon Sep 17 00:00:00 2001 From: Mia Winter Date: Thu, 29 Feb 2024 16:42:05 +0100 Subject: [PATCH] Implemented featured endpoint and embed script --- README.md | 2 +- Wave/Controllers/ApiController.cs | 38 ++++++++++++++ Wave/Data/Api/ArticleDto.cs | 28 ++++++++++ Wave/Data/Api/CategoryDto.cs | 9 ++++ Wave/Data/Api/UserDto.cs | 14 +++++ Wave/wwwroot/featured.js | 86 +++++++++++++++++++++++++++++++ 6 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 Wave/Controllers/ApiController.cs create mode 100644 Wave/Data/Api/ArticleDto.cs create mode 100644 Wave/Data/Api/CategoryDto.cs create mode 100644 Wave/Data/Api/UserDto.cs create mode 100644 Wave/wwwroot/featured.js diff --git a/README.md b/README.md index 8695e59..166a7b1 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ - # Wave - The Collaborative Open-Source Blogging Engine ## Stay afloat in a current of Information ![Wave License](https://img.shields.io/github/license/miawinter98/Wave?color=green) ![GitHub Forks](https://img.shields.io/github/forks/miawinter98/Wave?label=github%20forks&logo=github) ![GitHub Stars](https://img.shields.io/github/stars/miawinter98/Wave?label=github%20stars&color=yellow&logo=github) ![Docker Image Version](https://img.shields.io/docker/v/miawinter/wave?color=informational&logo=docker&label=latest) ![Docker Pulls](https://img.shields.io/docker/pulls/miawinter/wave?color=informational&logo=docker) ![Docker Stars](https://img.shields.io/docker/stars/miawinter/wave?color=yellow&logo=docker) ⚠ Under Construction ⚠ ## Quickstart This docker compose file will give you everything you need to run Wave. See the following sections for explanations about the configuration and makeup of Wave. Replace <*_password> with generated passwords, just in case, replace your-time-zone with a sensible time zone for your users. For extensive configuration you want to mount `/configuration` to a location on your system. Afterwards you can access Wave on `http://localhost`. To see how to create an admin account, read the following section. Afterwards for security you should [Configure an Email Server](#configuring-email). ``` version: '3.4' name: wave services: web: image: miawinter/wave:latest restart: unless-stopped ports: - "80:8080" links: - database:db environment: - "TZ=" - "WAVE_ConnectionStrings__DefaultConnection=Host=db; Username=wave; Password=" - "WAVE_ConnectionStrings__Redis=redis,password=" volumes: - wave-files:/app/files - wave-config:/configuration networks: - wave depends_on: - database database: image: postgres:16.1-alpine restart: unless-stopped environment: - "POSTGRES_DB=wave" - "POSTGRES_USER=wave" - "POSTGRES_PASSWORD=" volumes: - wave-db:/var/lib/postgresql/data networks: - wave redis: image: redis:7-alpine restart: unless-stopped command: redis-server --requirepass --save 60 1 --loglevel warning volumes: - wave-redis:/data networks: - wave volumes: wave-files: wave-config: wave-db: wave-redis: networks: wave: ``` Note: when binding the files volume to a local directory, keep in mind Wave runs by default on an internal "app" user (`1654:1654`), not root. You need to adjust your directory permissions or docker compose file accordingly (or just slap `chmod -R 777` on it). ### Admin Access When Wave does not detect any admin account in its database on startup, which usually happens during setup, a message will be printed to it's server console, in docker accessible with `docker logs wave-web-1`: `There is currently no user in your installation with the admin role, go to /Admin and use the following password to self promote your account: [password]` The password is 16 digits long, navigate to `http://localhost/Admin`, if you are not logged in you will be redirected to the login page. Once you are authenticated and have entered the password on the admin page, the tool will be disabled and you will be a member of the admin role, giving you full access to all of Waves' features. ~~Keep in mind that the password is generated every time on startup as long as there is no admin, so if you restart the container, there will be a different password in the console.~~ Since pull request #3 probably stays the same. ## Configuring Wave Wave allows you to configure it in many different formats and in multiple places, and you can even use multiple of the following methods to supply configuration information. Please keep in mind that first, asp.net configuration keys are case-insensitive, and second, that there is a precedence in the different formats, so a value for the same key in two formats will be overwritten by one. ### Configuration Locations There are two main locations where Wave (and asp.net) takes its configuration from: The Environment, and the `/configuration` volume. Environment variables allow you to quickly set up a docker container, but the more you need to configure the more unmaintainable an `.env` file (or an `environment:` section in docker compose) becomes, so if you find yourself customizing a lot of Waves behaviour, consider using one of the many supported configuration file formats. ### Configuration Keys I will provide you the different configuration keys with a dot notation, like `Email.Smtp.Host`. In environment variables, these dots need to be replaced with two underscore characters: `__` and prefixed with `WAVE_`. In config files, those dots are hierarchy level, and you need to implement that dialects' syntax for it. Here some examples for `Email.Smtp.Host`: **Environment** ``` WAVE_Email__Smtp__Host=smtp.example.com ``` **JSON** ```json { "Email": { "Smtp:": { "Host": "smtp.example.com" } } } ``` **YAML** ```yml Email: Smtp: Host: smtp.example.com ``` ### Supported Configuration Formats Wave will take configuration from the following files in the `/configuration` volume, files later in this chain will have precedence over files earlier in that chain: - config.json - config.yml - config.toml - config.ini - config.xml After this, values from the Environment will take the highest precedence. ## Configuring Email Wave may send user related mails every now and then, to confirm an account, reset a password, etc. In order to support that, Wave needs to have a way to send Emails, currently SMTP is supported. ### SMTP The following configuration is required for Wave to connect to a smtp server (formatted in YAML for brevity). ```yml Email: SenderEmail: noreply@example.com SenderName: Wave ServiceEmail: contact@example.com # used in various places, including email newsletter ListId Header Smtp: Live: Host: smtp.example.com Port: 25 Username: user Password: password Ssl: true ``` `Username` and `Password` are optional if your server does not require it, and `Ssl` is `true` by default, only set it to false if you really need to, keeping security in mind. ## Redis Wave will generate a variety of keys for anti-forgery and logged in users during it's runtime. By default, these will be persisted into an in-memory key store, which will be lost when restarting the Wave container, causing all users to be logged out. To persist these keys outside of the containers' lifetime, you can configure a Redis connection string using `ConnectionStrings.Redis`. ## Reverse Proxy In order to make your Wave installation available to the web, you want to use a reverse proxy to handle things like SSL Certificates. Here are some examples. ### Caddy In the Caddyfile add: ``` { encode gzip zstd @static path_regexp \.(js|css|svg|png|webp|jxl|jpg|jpeg|woff2)$ header ?Cache-Control "max-age=600" header @static Cache-Control "max-age=3600" header -server reverse_proxy localhost:8080 } ``` If Caddy runs as a docker container, you need to use the Wave container name instead of `localhost`. Adapt the Cache-Control header to your needs, this config sets a fallback of 600 (10 minutes) and 3600 (60 minutes) for static assets like fonts, images, js and css. Wave will set a Cache-Control header for certain files, only overwrite them if you know what you are doing. ### Nginx TODO ## RSS Wave by default supports retrieving your articles with rss, both the vanilla and the atom flavour. The RSS endpoints are located at `/rss/rss.xml` and `/rss/atom.xml`. They also allow you to filter for a category with a `category` query parameter like so: `/rss/atom.xml?category=cats`. If for some reason you wish to disable these endpoints and RSS with it, set the configuration key `Features.RSS` to false. Please don't disable it because you think it will make your site more secure or less scrapable, it does not. ## Email Subscriptions Wave can allow users to subscribe to E-Mail updates about new articles. In order to enable this feature you first need to set the configuration key `Features.EmailSubscriptions` to true. You also need to configure Emails in general, please follow the [Configure an Email Server](#configuring-email) Section for this. Besides this you will need to provide a mail distributor for these bulk E-Mails. This may be the same as you have already configured for live E-Mails, in this case just copy the configuration from there, but especially at larger volumes it is advisable to separate these concerns, and many hosting providers for large mail distribution will provide you with two sets of credentials for this. If you have followed all the previous instructions, your E-Mail configuration may look like this: ```yml Email: SenderEmail: noreply@example.com SenderName: Wave ServiceEmail: contact@example.com # used in various places, including email newsletter ListId Header Smtp: Live: Host: smtp.example.com Port: 25 Username: user Password: password Ssl: true Bulk: Host: bulk.smtp.example.com Port: 465 Username: bulk-user Password: bulk-password ``` ## Customizations TODO implement more customizations, add description. Currently supported: ```yml Customization: AppName: My cool blog AppDescription: This is where I write about my cats AppUrl: https://example.com DefaultTheme: wave-dark DefaultLanguage: "en-GB" LogoLink: https://miawinter.de/img/logo.png Footer: (c) 2024 [Mia Rose Winter](https://miawinter.de/) ``` ## Additional Notes TODO ? ## License and Attribution Wave by [Mia Winter](https://miawinter.de/) is licensed under the [MIT License](https://en.wikipedia.org/wiki/MIT_License). Copyright (c) 2024 Mia Rose Winter \ No newline at end of file + # Wave - The Collaborative Open-Source Blogging Engine ## Stay afloat in a current of Information ![Wave License](https://img.shields.io/github/license/miawinter98/Wave?color=green) ![GitHub Forks](https://img.shields.io/github/forks/miawinter98/Wave?label=github%20forks&logo=github) ![GitHub Stars](https://img.shields.io/github/stars/miawinter98/Wave?label=github%20stars&color=yellow&logo=github) ![Docker Image Version](https://img.shields.io/docker/v/miawinter/wave?color=informational&logo=docker&label=latest) ![Docker Pulls](https://img.shields.io/docker/pulls/miawinter/wave?color=informational&logo=docker) ![Docker Stars](https://img.shields.io/docker/stars/miawinter/wave?color=yellow&logo=docker) ⚠ Under Construction ⚠ ## Quickstart This docker compose file will give you everything you need to run Wave. See the following sections for explanations about the configuration and makeup of Wave. Replace <*_password> with generated passwords, just in case, replace your-time-zone with a sensible time zone for your users. For extensive configuration you want to mount `/configuration` to a location on your system. Afterwards you can access Wave on `http://localhost`. To see how to create an admin account, read the following section. Afterwards for security you should [Configure an Email Server](#configuring-email). ``` version: '3.4' name: wave services: web: image: miawinter/wave:latest restart: unless-stopped ports: - "80:8080" links: - database:db environment: - "TZ=" - "WAVE_ConnectionStrings__DefaultConnection=Host=db; Username=wave; Password=" - "WAVE_ConnectionStrings__Redis=redis,password=" volumes: - wave-files:/app/files - wave-config:/configuration networks: - wave depends_on: - database database: image: postgres:16.1-alpine restart: unless-stopped environment: - "POSTGRES_DB=wave" - "POSTGRES_USER=wave" - "POSTGRES_PASSWORD=" volumes: - wave-db:/var/lib/postgresql/data networks: - wave redis: image: redis:7-alpine restart: unless-stopped command: redis-server --requirepass --save 60 1 --loglevel warning volumes: - wave-redis:/data networks: - wave volumes: wave-files: wave-config: wave-db: wave-redis: networks: wave: ``` Note: when binding the files volume to a local directory, keep in mind Wave runs by default on an internal "app" user (`1654:1654`), not root. You need to adjust your directory permissions or docker compose file accordingly (or just slap `chmod -R 777` on it). ### Admin Access When Wave does not detect any admin account in its database on startup, which usually happens during setup, a message will be printed to it's server console, in docker accessible with `docker logs wave-web-1`: `There is currently no user in your installation with the admin role, go to /Admin and use the following password to self promote your account: [password]` The password is 16 digits long, navigate to `http://localhost/Admin`, if you are not logged in you will be redirected to the login page. Once you are authenticated and have entered the password on the admin page, the tool will be disabled and you will be a member of the admin role, giving you full access to all of Waves' features. ~~Keep in mind that the password is generated every time on startup as long as there is no admin, so if you restart the container, there will be a different password in the console.~~ Since pull request #3 probably stays the same. ## Configuring Wave Wave allows you to configure it in many different formats and in multiple places, and you can even use multiple of the following methods to supply configuration information. Please keep in mind that first, asp.net configuration keys are case-insensitive, and second, that there is a precedence in the different formats, so a value for the same key in two formats will be overwritten by one. ### Configuration Locations There are two main locations where Wave (and asp.net) takes its configuration from: The Environment, and the `/configuration` volume. Environment variables allow you to quickly set up a docker container, but the more you need to configure the more unmaintainable an `.env` file (or an `environment:` section in docker compose) becomes, so if you find yourself customizing a lot of Waves behaviour, consider using one of the many supported configuration file formats. ### Configuration Keys I will provide you the different configuration keys with a dot notation, like `Email.Smtp.Host`. In environment variables, these dots need to be replaced with two underscore characters: `__` and prefixed with `WAVE_`. In config files, those dots are hierarchy level, and you need to implement that dialects' syntax for it. Here some examples for `Email.Smtp.Host`: **Environment** ``` WAVE_Email__Smtp__Host=smtp.example.com ``` **JSON** ```json { "Email": { "Smtp:": { "Host": "smtp.example.com" } } } ``` **YAML** ```yml Email: Smtp: Host: smtp.example.com ``` ### Supported Configuration Formats Wave will take configuration from the following files in the `/configuration` volume, files later in this chain will have precedence over files earlier in that chain: - config.json - config.yml - config.toml - config.ini - config.xml After this, values from the Environment will take the highest precedence. ## Configuring Email Wave may send user related mails every now and then, to confirm an account, reset a password, etc. In order to support that, Wave needs to have a way to send Emails, currently SMTP is supported. ### SMTP The following configuration is required for Wave to connect to a smtp server (formatted in YAML for brevity). ```yml Email: SenderEmail: noreply@example.com SenderName: Wave ServiceEmail: contact@example.com # used in various places, including email newsletter ListId Header Smtp: Live: Host: smtp.example.com Port: 25 Username: user Password: password Ssl: true ``` `Username` and `Password` are optional if your server does not require it, and `Ssl` is `true` by default, only set it to false if you really need to, keeping security in mind. ## Redis Wave will generate a variety of keys for anti-forgery and logged in users during it's runtime. By default, these will be persisted into an in-memory key store, which will be lost when restarting the Wave container, causing all users to be logged out. To persist these keys outside of the containers' lifetime, you can configure a Redis connection string using `ConnectionStrings.Redis`. ## Reverse Proxy In order to make your Wave installation available to the web, you want to use a reverse proxy to handle things like SSL Certificates. Here are some examples. ### Caddy In the Caddyfile add: ``` { encode gzip zstd @static path_regexp \.(js|css|svg|png|webp|jxl|jpg|jpeg|woff2)$ header ?Cache-Control "max-age=600" header @static Cache-Control "max-age=3600" header -server reverse_proxy localhost:8080 } ``` If Caddy runs as a docker container, you need to use the Wave container name instead of `localhost`. Adapt the Cache-Control header to your needs, this config sets a fallback of 600 (10 minutes) and 3600 (60 minutes) for static assets like fonts, images, js and css. Wave will set a Cache-Control header for certain files, only overwrite them if you know what you are doing. ### Nginx TODO ## RSS Wave by default supports retrieving your articles with rss, both the vanilla and the atom flavour. The RSS endpoints are located at `/rss/rss.xml` and `/rss/atom.xml`. They also allow you to filter for a category with a `category` query parameter like so: `/rss/atom.xml?category=cats`. If for some reason you wish to disable these endpoints and RSS with it, set the configuration key `Features.RSS` to false. Please don't disable it because you think it will make your site more secure or less scrapable, it does not. ## Email Subscriptions Wave can allow users to subscribe to E-Mail updates about new articles. In order to enable this feature you first need to set the configuration key `Features.EmailSubscriptions` to true. You also need to configure Emails in general, please follow the [Configure an Email Server](#configuring-email) Section for this. Besides this you will need to provide a mail distributor for these bulk E-Mails. This may be the same as you have already configured for live E-Mails, in this case just copy the configuration from there, but especially at larger volumes it is advisable to separate these concerns, and many hosting providers for large mail distribution will provide you with two sets of credentials for this. If you have followed all the previous instructions, your E-Mail configuration may look like this: ```yml Email: SenderEmail: noreply@example.com SenderName: Wave ServiceEmail: contact@example.com # used in various places, including email newsletter ListId Header Smtp: Live: Host: smtp.example.com Port: 25 Username: user Password: password Ssl: true Bulk: Host: bulk.smtp.example.com Port: 465 Username: bulk-user Password: bulk-password ``` ## Customizations TODO implement more customizations, add description. Currently supported: ```yml Customization: AppName: My cool blog AppDescription: This is where I write about my cats AppUrl: https://example.com DefaultTheme: wave-dark DefaultLanguage: "en-GB" LogoLink: https://miawinter.de/img/logo.png Footer: (c) 2024 [Mia Rose Winter](https://miawinter.de/) ``` ## Featured Article Embeds Wave allows access to the most recent article via the api endpoint `/api/article/featured`. This returns a json object with data about it, but there is also a script `/featured.js` that requests this automatically and inserts it into the page. The simplest embed in your page looks like this: ```
Loading featured article...
``` `data-wave-pfp-size` may be omitted, the default is 150. Wave will inject a default styled hero into the div with the data tag `data-wave`. If you want to provide your own template, you can use an html template: ``` ``` This is the defautl template by the way. Any property in the json response of `/api/article/featured` corresponds to a `data-wave-*` attribute. With `author` and `reviewer` having their own nested tags as `data-wave-author-*` and `data-wave-review-*` respectively. So to get the name of the article author, one could do this: ``` ``` Values will always be inserted with `innerText`, except if the target tag is a link, then `href` is used, or an image tag, then `src` is used. ## Additional Notes TODO ? ## License and Attribution Wave by [Mia Winter](https://miawinter.de/) is licensed under the [MIT License](https://en.wikipedia.org/wiki/MIT_License). Copyright (c) 2024 Mia Rose Winter \ No newline at end of file diff --git a/Wave/Controllers/ApiController.cs b/Wave/Controllers/ApiController.cs new file mode 100644 index 0000000..99e3f3a --- /dev/null +++ b/Wave/Controllers/ApiController.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using System.ComponentModel.DataAnnotations; +using Wave.Data; +using Wave.Data.Api; + +namespace Wave.Controllers; + +[ApiController] +[Route("/[controller]")] +public class ApiController(ApplicationDbContext context, IOptions customizationOptions) : ControllerBase { + + [HttpGet("article/featured")] + [Produces("application/json")] + public async Task, NoContent>> GetArticleFeatured([FromQuery, Range(16, 800)] int profilePictureSize = 800) { + Response.Headers.AccessControlAllowOrigin = "*"; + + var article = await context.Set
() + .IgnoreAutoIncludes() + .Include(a => a.Author).ThenInclude(a => a.Articles) + .Include(a => a.Reviewer) + .Include(a => a.Categories) + .OrderByDescending(a => a.PublishDate).ThenBy(a => a.Id) + .FirstOrDefaultAsync(); + if (article is null) return TypedResults.NoContent(); + + return TypedResults.Ok(ArticleDto.GetFromArticle(article, GetHost(), profilePictureSize)); + } + + private Uri GetHost() { + string customUrl = customizationOptions.Value.AppUrl; + + if (!string.IsNullOrEmpty(customUrl)) return new Uri(customUrl, UriKind.Absolute); + return new Uri($"{Request.Scheme}://{Request.Host}"); + } +} \ No newline at end of file diff --git a/Wave/Data/Api/ArticleDto.cs b/Wave/Data/Api/ArticleDto.cs new file mode 100644 index 0000000..b96e0dc --- /dev/null +++ b/Wave/Data/Api/ArticleDto.cs @@ -0,0 +1,28 @@ +using Wave.Utilities; + +namespace Wave.Data.Api; + +public record ArticleDto( + string Title, + string ContentPreview, + string BrowserUrl, + DateTimeOffset PublishDate, + UserDto Author, + UserDto? Reviewer, + IList Categories) { + + public static ArticleDto GetFromArticle(Article article, Uri host, int pfpSize) { + string browserLink = ArticleUtilities.GenerateArticleLink(article, host); + + var author = UserDto.GetFromUser(article.Author, host, pfpSize); + var reviewer = + article.Reviewer is not null && + article.Reviewer.Id != article.Author.Id + ? UserDto.GetFromUser(article.Reviewer, host, pfpSize) : null; + + var categories = article.Categories.Select(c => new CategoryDto(c)).ToArray(); + string preview = article.BodyPlain[..Math.Min(article.BodyPlain.Length, 500)]; + + return new ArticleDto(article.Title, preview, browserLink, article.PublishDate, author, reviewer, categories); + } +} \ No newline at end of file diff --git a/Wave/Data/Api/CategoryDto.cs b/Wave/Data/Api/CategoryDto.cs new file mode 100644 index 0000000..8282f07 --- /dev/null +++ b/Wave/Data/Api/CategoryDto.cs @@ -0,0 +1,9 @@ +using Wave.Utilities; + +namespace Wave.Data.Api; + +public record CategoryDto(string Name, string Role) { + + public CategoryDto(Category category) + : this(category.Name, CategoryUtilities.GetCssClassPostfixForColor(category.Color)) {} +} \ No newline at end of file diff --git a/Wave/Data/Api/UserDto.cs b/Wave/Data/Api/UserDto.cs new file mode 100644 index 0000000..a5ec312 --- /dev/null +++ b/Wave/Data/Api/UserDto.cs @@ -0,0 +1,14 @@ +namespace Wave.Data.Api; + +public record UserDto( + string Name, + string ProfilePictureUrl, + string? ProfileUrl) { + + public static UserDto GetFromUser(ApplicationUser user, Uri host, int pfpSize) { + var pfpUrl = new Uri(host, $"/api/User/pfp/{user.Id}?size={pfpSize}"); + var profileUrl = user.Articles.Count > 0 ? new Uri(host, $"/profile/{user.Id}") : null; + + return new UserDto(user.FullName ?? "Guest Author", pfpUrl.AbsoluteUri, profileUrl?.AbsoluteUri); + } +} \ No newline at end of file diff --git a/Wave/wwwroot/featured.js b/Wave/wwwroot/featured.js new file mode 100644 index 0000000..ce92fc4 --- /dev/null +++ b/Wave/wwwroot/featured.js @@ -0,0 +1,86 @@ +const doInsertText = function(dataName, content) { + const element = document.querySelector(`[data-wave-${dataName}]`); + if (element) { + if (element.tagName === "A") { + element.href = content; + } else if (element.tagName === "IMG") { + element.src = content; + } else { + element.innerText = content; + } + } +} + +const script = document.getElementById("wave-script"); +if (!script) { + throw new Error("[WAVE] no script with the id 'wave-script' exists."); +} +if (!script.src) { + throw new Error("[WAVE] failed to get src attribute of element with id 'wave-script'."); +} +const scriptUrl = new URL(script.src); +const host = `${scriptUrl.protocol}//${scriptUrl.host}`; + +let pfpSize = 150; +const container = document.querySelector("[data-wave]"); + +if (container && container.dataset.wavePfpSize) { + const value = parseInt(container.dataset.wavePfpSize); + if (value && value > 800) console.log("[WAVE] WARNING: pfp sizes greater 800 are not supported."); + else if (value) pfpSize = value; + else console.log( + "[WAVE] WARNING: a custom pfp size has been provided with 'data-wave-pfp-size', " + + "but it's value could not be parsed as an integer."); +} + +console.log("[WAVE] requesting featured article"); +fetch(new URL("/api/article/featured?size=" + pfpSize, host), + { + method: "GET", + headers: new Headers({ + "Accept": "application/json" + }) + }) + .then(response => response.json()) + .then(function(result) { + + if (container) { + const template = document.querySelector("[data-wave-template]"); + + if (template) { + container.innerHTML = ""; + container.appendChild(template.content.cloneNode(true)); + } else { + container.innerHTML = ` +
+

+ +

+ +
+
+
+
+ Read More +

+ +
+`; + } + + for (let [key, value] of Object.entries(result)) { + doInsertText(key, value); + if (typeof value === "object" && value != null) { + for (let [innerKey, innerValue] of Object.entries(value)) { + doInsertText(key + "-" + innerKey, innerValue); + } + } + } + + console.log("[WAVE] fetched feature successfully."); + } else { + console.log("[WAVE] no container found, to use featured you require an element with the data tag 'wave'."); + } + }) + .catch(err => console.log(`[WAVE] failed to request featured article: ${err}.`));