├── .formatter.exs ├── .gitignore ├── .vscode └── settings.json ├── Dockerfile ├── LICENSE.md ├── README.md ├── assets ├── css │ ├── app.css │ └── phoenix.css ├── js │ ├── app.js │ └── updates_chart.js └── vendor │ └── topbar.js ├── config ├── config.exs ├── dev.exs ├── prod.exs ├── runtime.exs └── test.exs ├── lib ├── websubhub.ex ├── websubhub │ ├── application.ex │ ├── jobs │ │ └── dispatch_plain_update.ex │ ├── mailer.ex │ ├── release.ex │ ├── repo.ex │ ├── subscriptions.ex │ ├── subscriptions │ │ ├── subscription.ex │ │ └── topic.ex │ ├── updates.ex │ └── updates │ │ ├── headers.ex │ │ ├── subscription_update.ex │ │ └── update.ex ├── websubhub_web.ex └── websubhub_web │ ├── controllers │ └── hub_controller.ex │ ├── endpoint.ex │ ├── gettext.ex │ ├── live │ ├── index_page.ex │ ├── index_page.html.heex │ └── status_page.ex │ ├── router.ex │ ├── telemetry.ex │ ├── templates │ └── layout │ │ ├── app.html.heex │ │ ├── live.html.heex │ │ └── root.html.heex │ └── views │ ├── error_helpers.ex │ ├── error_view.ex │ ├── layout_view.ex │ └── page_view.ex ├── mix.exs ├── mix.lock ├── priv ├── gettext │ ├── en │ │ └── LC_MESSAGES │ │ │ └── errors.po │ └── errors.pot ├── repo │ ├── migrations │ │ ├── .formatter.exs │ │ ├── 20220108154834_create_topics.exs │ │ ├── 20220108233614_add_oban_jobs_table.exs │ │ ├── 20220126190939_add_unique_constaint_to_subscriptions.exs │ │ └── 20220126195116_longer_everything.exs │ └── seeds.exs └── static │ ├── favicon.ico │ ├── images │ └── phoenix.png │ └── robots.txt └── test ├── support ├── channel_case.ex ├── conn_case.ex ├── data_case.ex └── fixtures │ └── subscriptions_fixtures.ex ├── test_helper.exs ├── websubhub ├── hub_test.exs ├── subscriptions_test.exs └── updates_test.exs └── websubhub_web ├── controllers ├── hub_controller_test.exs └── page_controller_test.exs └── views ├── error_view_test.exs ├── layout_view_test.exs └── page_view_test.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto, :phoenix], 3 | inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | subdirectories: ["priv/*/migrations"] 5 | ] 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | websubhub-*.tar 24 | 25 | # Ignore assets that are produced by build tools. 26 | /priv/static/assets/ 27 | 28 | # Ignore digested assets cache. 29 | /priv/static/cache_manifest.json 30 | 31 | # In case you use Node.js/npm, you want to ignore these. 32 | npm-debug.log 33 | /assets/node_modules/ 34 | 35 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM elixir:1.13.1-alpine AS build 2 | 3 | # prepare build dir 4 | WORKDIR /app 5 | 6 | # install hex + rebar 7 | RUN mix local.hex --force && \ 8 | mix local.rebar --force 9 | 10 | # set build ENV 11 | ENV MIX_ENV=prod 12 | 13 | # install mix dependencies 14 | COPY mix.exs mix.lock ./ 15 | COPY config config 16 | RUN mix do deps.get, deps.compile 17 | 18 | COPY priv priv 19 | COPY assets assets 20 | RUN mix assets.deploy 21 | 22 | # compile and build release 23 | COPY lib lib 24 | # uncomment COPY if rel/ exists 25 | # COPY rel rel 26 | RUN mix do compile, release 27 | 28 | # prepare release image 29 | FROM alpine:3.12 AS app 30 | RUN apk add --no-cache openssl gcc libc-dev ncurses-libs 31 | 32 | WORKDIR /app 33 | 34 | RUN chown nobody:nobody /app 35 | 36 | USER nobody:nobody 37 | 38 | COPY --from=build --chown=nobody:nobody /app/_build/prod/rel/websubhub ./ 39 | 40 | ENV HOME=/app 41 | 42 | CMD ["bin/websubhub", "start"] 43 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Luke Strickland 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebSubHub 2 | 3 | WebSubHub is a fully compliant WebSub Hub built that you can use to distribute live changes from various publishers. Usage of WebSubHub is very simple with only a single endpoint available at https://websubhub.com/hub. 4 | 5 | ## Development 6 | You can setup your own development / production environment of WebSubHub easily by grabbing your dependencies, creating your database, and running the server. 7 | 8 | * Install dependencies with `mix deps.get` 9 | * Create and migrate your database with `mix ecto.setup` 10 | * Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server` 11 | 12 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 13 | 14 | Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). 15 | 16 | 17 | ### Contributing 18 | 1. [Fork it!](http://github.com/clone1018/WebSubHub.tv/fork) 19 | 2. Create your feature branch (`git checkout -b feature/my-new-feature`) 20 | 3. Commit your changes (`git commit -am 'Add some feature'`) 21 | 4. Push to the branch (`git push origin feature/my-new-feature`) 22 | 5. Create new Pull Request 23 | 24 | 25 | ## Testing 26 | WebSubHub includes a comprehensive and very fast test suite, so you should be encouraged to run tests as frequently as possible. 27 | 28 | ```sh 29 | mix test 30 | ``` 31 | 32 | ## Help 33 | If you need help with anything, please feel free to open [a GitHub Issue](https://github.com/clone1018/WebSubHub/issues/new). 34 | 35 | ## License 36 | WebSubHub is licensed under the [MIT License](LICENSE.md). -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --unit: 1rem; 3 | --unit-2: 2rem; 4 | --unit-3: 3rem; 5 | --leading: 1.4; 6 | --measure: 960px; 7 | --measure-min: 288px; 8 | --font: -apple-system, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; 9 | --font-header: var(--font); 10 | --color-body: #1a1a1a; 11 | --color-body-light: #626A6E; 12 | --color-body-inverted: #fff; 13 | --color-select: #b3d4fc; 14 | --color-access: #fd0; 15 | --color-error: #f00; 16 | --color-line: #b1b4b6; 17 | --color-light: #505050; 18 | --color-very-light: #f3f2f1; 19 | --color-primary: #005ea5; 20 | --color-primary-hover: #003078; 21 | --color-primary-active: #2b8cc4; 22 | --color-primary-visited: #4c2c92; 23 | --color-secondary: #005a30; 24 | --color-secondary-hover: #003E21; 25 | --color-secondary-active: #003E21; 26 | --color-secondary-visited: #00703c; 27 | --color-tertiary: #942514; 28 | --color-tertiary-hover: #6F0000; 29 | --color-tertiary-active: #6F0000; 30 | --color-tertiary-visited: #6F0000 31 | } 32 | 33 | .hide { 34 | position: absolute; 35 | left: -10000px; 36 | top: auto; 37 | width: 1px; 38 | height: 1px; 39 | overflow: hidden 40 | } 41 | 42 | ::placeholder { 43 | color: var(--color-body-light) 44 | } 45 | 46 | ::selection { 47 | background: var(--color-select); 48 | text-shadow: none 49 | } 50 | 51 | html { 52 | margin: 0; 53 | padding: 0; 54 | background: var(--color-body) 55 | } 56 | 57 | body { 58 | background: var(--color-body-inverted); 59 | margin: 0; 60 | padding: 0; 61 | font-family: var(--font); 62 | font-size: 100%; 63 | line-height: var(--leading); 64 | color: var(--color-body) 65 | } 66 | 67 | header { 68 | background: var(--color-body); 69 | color: var(--color-body-inverted); 70 | padding: var(--unit); 71 | overflow: hidden; 72 | /* display: grid */ 73 | } 74 | 75 | main header { 76 | padding-top: var(--unit-2); 77 | padding-bottom: var(--unit-2); 78 | color: var(--color-body); 79 | background: none; 80 | text-align: center 81 | } 82 | 83 | nav { 84 | /* display: grid; */ 85 | /* max-width: calc(var(--measure) - var(--unit-2)); */ 86 | /* grid-template-columns: repeat(auto-fit, 1fr); */ 87 | /* margin: var(--unit) auto; */ 88 | /* padding: 0 var(--unit-1); */ 89 | /* width: 100% */ 90 | } 91 | 92 | nav ol, 93 | nav ul { 94 | font-size: 1rem 95 | } 96 | 97 | header nav { 98 | /* padding: 0 var(--unit) */ 99 | max-width: var(--measure); 100 | margin: 0 auto; 101 | } 102 | 103 | header nav :last-child { 104 | text-align: right 105 | } 106 | 107 | header nav h1 { 108 | margin: 0; 109 | text-align: left !important; 110 | } 111 | 112 | nav a { 113 | color: var(--color-body) 114 | } 115 | 116 | nav ol, 117 | nav ul { 118 | margin: var(--unit) 0; 119 | padding: 0 120 | } 121 | 122 | header+nav { 123 | border-top: 10px solid var(--color-primary); 124 | max-width: var(--measure); 125 | margin: 0 auto var(--unit); 126 | padding: 0 var(--unit) 127 | } 128 | 129 | nav li { 130 | display: inline 131 | } 132 | 133 | header nav li+li { 134 | margin-left: var(--unit) 135 | } 136 | 137 | header+nav li+li:before { 138 | content: ''; 139 | display: inline-block; 140 | width: .5em; 141 | height: .5em; 142 | border: none; 143 | border-top: 1px solid var(--color-line); 144 | border-right: 1px solid var(--color-line); 145 | transform: rotate(45deg); 146 | margin: 0 .5em 0 0 147 | } 148 | 149 | article, 150 | main { 151 | padding: 0 var(--unit); 152 | margin: 0 auto; 153 | max-width: var(--measure) 154 | } 155 | 156 | main article { 157 | padding: var(--unit) 0; 158 | max-width: 640px 159 | } 160 | 161 | p { 162 | font-size: 1.1875rem 163 | } 164 | 165 | a { 166 | color: var(--color-primary) 167 | } 168 | 169 | a:hover { 170 | color: var(--color-primary-hover) 171 | } 172 | 173 | a:active { 174 | color: var(--color-primary-active) 175 | } 176 | 177 | a:focus { 178 | outline-offset: 0; 179 | outline: 3px solid transparent; 180 | color: var(--color-body); 181 | text-decoration: none; 182 | background-color: var(--color-access); 183 | box-shadow: 0 -2px var(--color-access), 0 2px var(--color-body) 184 | } 185 | 186 | a[href="javascript:history.back()"]:before, 187 | a[href="#top"]:before, 188 | figcaption:before { 189 | content: ''; 190 | display: inline-block; 191 | width: 0; 192 | height: 0; 193 | border-style: solid; 194 | border-color: transparent; 195 | border-right-color: transparent; 196 | clip-path: polygon(0% 50%, 100% 100%, 100% 0%); 197 | border-width: 5px 6px 5px 0; 198 | border-right-color: inherit; 199 | margin-right: .5em 200 | } 201 | 202 | a[href="#top"]:before { 203 | transform: rotate(90deg) 204 | } 205 | 206 | a[href*="://"]:after { 207 | content: ' ↗'; 208 | text-decoration: none !important 209 | } 210 | 211 | body>header a { 212 | color: var(--color-body-inverted); 213 | font-weight: 700; 214 | text-decoration: none 215 | } 216 | 217 | body>header a:hover { 218 | text-decoration: underline; 219 | color: var(--color-body-inverted) 220 | } 221 | 222 | h1, 223 | h2, 224 | h3, 225 | h4, 226 | h5, 227 | h6 { 228 | font-weight: bold; 229 | line-height: 1.2; 230 | margin: 1em 0 .6em; 231 | } 232 | 233 | h1 { 234 | font-size: 3.1579rem 235 | } 236 | 237 | h2 { 238 | font-size: 3rem 239 | } 240 | 241 | h3 { 242 | font-size: 2.25rem 243 | } 244 | 245 | h4 { 246 | font-size: 1.6875rem 247 | } 248 | 249 | h5 { 250 | font-size: 1.5rem 251 | } 252 | 253 | h6 { 254 | font-size: 1.1875rem 255 | } 256 | 257 | hgroup { 258 | margin-top: var(--unit-2); 259 | padding: var(--unit-2) var(--unit-1) 260 | } 261 | 262 | hgroup> :first-child { 263 | font-weight: 400; 264 | color: var(--color-body-light) 265 | } 266 | 267 | hgroup :last-child { 268 | font-weight: 700; 269 | margin-top: -.25em 270 | } 271 | 272 | hr { 273 | background: var(--color-line); 274 | height: 1px; 275 | border: none; 276 | margin: var(--unit-3) 0 277 | } 278 | 279 | section { 280 | display: grid; 281 | grid-template-columns: repeat(auto-fit, minmax(var(--measure-min), 1fr)); 282 | grid-column-gap: var(--unit-3) 283 | } 284 | 285 | section header { 286 | grid-column: 1/-1 287 | } 288 | 289 | section article { 290 | grid-column: span 2; 291 | padding-top: 0 292 | } 293 | 294 | form { 295 | display: block; 296 | margin: 0; 297 | padding: 0 298 | } 299 | 300 | fieldset { 301 | box-sizing: content-box; 302 | position: relative; 303 | border: 1px solid var(--color-line); 304 | border-top: none; 305 | border-right: none; 306 | padding: 1rem; 307 | margin: 0 0 var(--unit-2); 308 | position: relative; 309 | padding-top: 3rem 310 | } 311 | 312 | fieldset:before { 313 | content: ''; 314 | border: 1px solid var(--color-line); 315 | height: 3rem; 316 | position: absolute; 317 | top: -3.0625rem; 318 | top: 0; 319 | left: -1px; 320 | right: -1px; 321 | z-index: 0 322 | } 323 | 324 | fieldset:after { 325 | content: ''; 326 | border-right: 1px solid var(--color-line); 327 | position: absolute; 328 | top: 0; 329 | bottom: -1px; 330 | right: -1px 331 | } 332 | 333 | legend { 334 | display: block; 335 | position: absolute; 336 | top: 0; 337 | margin: 0 0 0 -1.0625rem; 338 | padding: var(--unit); 339 | border: 1px solid var(--color-line); 340 | border-bottom: none; 341 | font-size: 1rem; 342 | line-height: 1; 343 | background: var(--color-body-inverted) 344 | } 345 | 346 | legend:after { 347 | content: ''; 348 | border-top: 1px solid var(--color-body-inverted); 349 | position: absolute; 350 | bottom: -1px; 351 | left: 0; 352 | right: 0; 353 | z-index: 2 354 | } 355 | 356 | label { 357 | display: block; 358 | margin-bottom: calc(var(--unit) / 2) 359 | } 360 | 361 | button, 362 | input[type="submit"], 363 | input[type="button"], 364 | input[type="reset"] { 365 | width: auto; 366 | height: 2.5em; 367 | background-color: var(--color-secondary); 368 | border: 2px solid transparent; 369 | box-shadow: var(--color-body) 0 2px 0 0; 370 | box-sizing: border-box; 371 | color: #fff; 372 | cursor: pointer; 373 | display: inline-block; 374 | font-family: var(--font); 375 | font-size: 1.1875rem; 376 | font-weight: 400; 377 | line-height: 1; 378 | margin-bottom: var(--unit-2); 379 | margin-top: 0; 380 | text-align: center; 381 | vertical-align: baseline; 382 | -moz-appearance: none; 383 | -moz-osx-font-smoothing: grayscale; 384 | padding: 8px 10px 7px 385 | } 386 | 387 | input[type="button"] { 388 | background-color: var(--color-primary) 389 | } 390 | 391 | input[type="button"]:hover { 392 | background-color: var(--color-primary-hover) 393 | } 394 | 395 | input[type="reset"] { 396 | background-color: var(--color-tertiary) 397 | } 398 | 399 | input[type="reset"]:hover { 400 | background-color: var(--color-tertiary-hover) 401 | } 402 | 403 | button:hover, 404 | input[type="submit"]:hover { 405 | background: var(--color-secondary-hover) 406 | } 407 | 408 | button:active, 409 | input[type="submit"]:active, 410 | input[type="button"]:active, 411 | input[type="reset"]:active { 412 | transform: translateY(2px) 413 | } 414 | 415 | fieldset button, 416 | fieldset input[type="submit"], 417 | input[type="button"], 418 | input[type="reset"] { 419 | margin-bottom: 0 420 | } 421 | 422 | input[type="image"] { 423 | width: 40px; 424 | height: 40px; 425 | padding: 0; 426 | display: inline-block; 427 | background: var(--color-body) 428 | } 429 | 430 | input[type="search"] { 431 | width: calc(100% - 40px); 432 | display: inline-block; 433 | float: left 434 | } 435 | 436 | input, 437 | output { 438 | display: inline-block; 439 | font-size: 1.1875rem; 440 | line-height: var(--leading); 441 | font-family: var(--font); 442 | -webkit-font-smoothing: antialiased; 443 | -moz-osx-font-smoothing: grayscale; 444 | font-weight: 400; 445 | box-sizing: border-box; 446 | width: 100%; 447 | height: 2.5rem; 448 | margin-top: 0; 449 | padding: 5px; 450 | border: 2px solid var(--color-body); 451 | border-radius: 0; 452 | appearance: none 453 | } 454 | 455 | output { 456 | font-weight: 700 457 | } 458 | 459 | input[type="file"] { 460 | padding: 0 7px 0 0; 461 | border: none 462 | } 463 | 464 | input[type="color"] { 465 | width: 3rem 466 | } 467 | 468 | input[type="checkbox"], 469 | input[type="radio"] { 470 | position: absolute; 471 | left: -10000px; 472 | top: auto; 473 | width: 1px; 474 | height: 1px; 475 | overflow: hidden 476 | } 477 | 478 | input[type="radio"]+label { 479 | display: inline-block; 480 | position: relative; 481 | padding-left: 3.5rem; 482 | padding-right: 2rem; 483 | padding-bottom: 0.75rem; 484 | cursor: pointer 485 | } 486 | 487 | input[type="radio"]+label:before { 488 | content: ""; 489 | display: inline-block; 490 | box-sizing: border-box; 491 | position: absolute; 492 | left: -.5rem; 493 | top: 0; 494 | margin-top: -.75rem; 495 | vertical-align: baseline; 496 | width: 3rem; 497 | height: 3rem; 498 | background: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"%3E%3Cg fill="none" fill-rule="evenodd" stroke="%23FFF" transform="translate(1 1)"%3E%3Ccircle cx="23.5" cy="23.5" r="21.5" fill="%23000" stroke-width="4"/%3E%3Ccircle cx="23.5" cy="23.5" r="14" fill="%23FFF" stroke-width="7"/%3E%3C/g%3E%3C/svg%3E'); 499 | cursor: pointer; 500 | background-size: cover 501 | } 502 | 503 | input[type="radio"]:checked+label:before { 504 | background: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"%3E%3Cg fill="%23000" fill-rule="evenodd" stroke="%23FFF" transform="translate(1 1)"%3E%3Ccircle cx="23.5" cy="23.5" r="21.5" stroke-width="4"/%3E%3Ccircle cx="23.5" cy="23.5" r="14" stroke-width="7"/%3E%3C/g%3E%3C/svg%3E') 505 | } 506 | 507 | input[type="radio"]:focus+label:before { 508 | background: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"%3E%3Cg fill="none" fill-rule="evenodd" transform="translate(1 1)"%3E%3Ccircle cx="23.5" cy="23.5" r="21.5" fill="%23000" stroke="%23FD0" stroke-width="4"/%3E%3Ccircle cx="23.5" cy="23.5" r="13" fill="%23FFF" stroke="%23FFF" stroke-width="6"/%3E%3C/g%3E%3C/svg%3E%0A') 509 | } 510 | 511 | input[type="radio"]:focus:checked+label:before { 512 | background: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"%3E%3Cg fill="%23000" fill-rule="evenodd" transform="translate(1 1)"%3E%3Ccircle cx="23.5" cy="23.5" r="21.5" stroke="%23FD0" stroke-width="4"/%3E%3Ccircle cx="23.5" cy="23.5" r="13" stroke="%23FFF" stroke-width="6"/%3E%3C/g%3E%3C/svg%3E') 513 | } 514 | 515 | input[type="checkbox"]+label { 516 | position: relative; 517 | display: inline-block; 518 | padding-left: 3.5rem; 519 | padding-right: 2rem; 520 | padding-bottom: 0.75rem; 521 | cursor: pointer 522 | } 523 | 524 | input[type="checkbox"]+label:before { 525 | content: ""; 526 | display: inline-block; 527 | box-sizing: border-box; 528 | position: absolute; 529 | left: -.25rem; 530 | top: 0; 531 | margin-top: -.75rem; 532 | vertical-align: baseline; 533 | width: 3rem; 534 | height: 3rem; 535 | background: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 49 49"%3E%3Cg fill="none" fill-rule="evenodd"%3E%3Cpath fill="%23000" stroke="%23FFF" stroke-width="3" d="M2 2h45v45H2z"/%3E%3Cpath fill="%23FFF" stroke="%23000" stroke-width="2" d="M4.5 4.5h40v40h-40z"/%3E%3C/g%3E%3C/svg%3E%0A'); 536 | cursor: pointer; 537 | background-size: cover 538 | } 539 | 540 | input[type="checkbox"]:checked+label:before { 541 | background: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 49 49"%3E%3Cg fill="none" fill-rule="evenodd"%3E%3Cpath fill="%23000" stroke="%23FFF" stroke-width="3" d="M2 2.4h45v45H2z"/%3E%3Cpath fill="%23FFF" stroke="%23000" stroke-width="2" d="M4.5 5h40v40h-40z"/%3E%3Cpath fill="%23000" d="M15.6 23.4l5 5 12.7-12.8 3.6 3.6-16.3 16.2L12 27z"/%3E%3C/g%3E%3C/svg%3E') 542 | } 543 | 544 | input[type="checkbox"]:focus+label:before { 545 | background: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 49 49"%3E%3Cg fill="none" fill-rule="evenodd"%3E%3Cpath fill="%23000" stroke="%23FD0" stroke-width="3" d="M2 2h45v45H2z"/%3E%3Cpath fill="%23FFF" stroke="%23000" stroke-width="4" d="M5.5 5.5h38v38h-38z"/%3E%3C/g%3E%3C/svg%3E%0A') 546 | } 547 | 548 | input[type="checkbox"]:focus:checked+label:before { 549 | background: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 49 49"%3E%3Cg fill="none" fill-rule="evenodd"%3E%3Cpath fill="%23000" stroke="%23FD0" stroke-width="3" d="M2 2h45v45H2z"/%3E%3Cpath fill="%23FFF" stroke="%23000" stroke-width="4" d="M5.5 5.5h38v38h-38z"/%3E%3Cpath fill="%23000" d="M15.6 23l5 5 12.7-12.8 3.6 3.5L20.6 35 12 26.5z"/%3E%3C/g%3E%3C/svg%3E%0A') 550 | } 551 | 552 | input:disabled { 553 | cursor: default 554 | } 555 | 556 | input[type="time"] { 557 | width: 7rem 558 | } 559 | 560 | input[type="date"], 561 | input[type="week"], 562 | input[type="month"] { 563 | width: 14rem 564 | } 565 | 566 | input[type="datetime"], 567 | input[type="datetime-local"] { 568 | width: 16rem 569 | } 570 | 571 | select { 572 | font-size: 1.1875rem; 573 | line-height: 1.25; 574 | box-sizing: border-box; 575 | font-family: var(--font); 576 | font-weight: 400; 577 | max-width: 100%; 578 | min-width: 14rem; 579 | height: 40px; 580 | height: 2.5rem; 581 | padding: 5px; 582 | border: 2px solid var(--color-body); 583 | border-radius: 0; 584 | -webkit-border-radius: 0; 585 | -webkit-font-smoothing: antialiased; 586 | -moz-osx-font-smoothing: grayscale 587 | } 588 | 589 | textarea { 590 | display: block; 591 | box-sizing: border-box; 592 | width: 100%; 593 | padding: 5px; 594 | font-family: var(--font); 595 | min-height: 5.375rem; 596 | margin-bottom: var(--unit); 597 | font-size: 1.1875rem; 598 | line-height: 1.25; 599 | font-weight: 400; 600 | resize: vertical; 601 | border: 2px solid var(--color-body); 602 | border-radius: 0; 603 | -webkit-appearance: none; 604 | -webkit-font-smoothing: antialiased; 605 | -moz-osx-font-smoothing: grayscale; 606 | -webkit-box-sizing: border-box 607 | } 608 | 609 | textarea:focus, 610 | input:focus, 611 | button:focus, 612 | select:focus { 613 | outline: 3px solid var(--color-access); 614 | outline-offset: 0; 615 | box-shadow: inset 0 0 0 2px 616 | } 617 | 618 | input[type=range] { 619 | -webkit-appearance: none; 620 | width: 100%; 621 | margin: 0; 622 | border: none; 623 | padding: 0 624 | } 625 | 626 | input[type=range]:focus { 627 | outline: none; 628 | box-shadow: none 629 | } 630 | 631 | input[type=range]::-webkit-slider-runnable-track { 632 | width: 100%; 633 | height: 4px; 634 | cursor: pointer; 635 | box-shadow: 1px 1px 1px rgba(0, 0, 0, 0), 0 0 1px rgba(13, 13, 13, 0); 636 | background: rgba(80, 80, 80, 0.5); 637 | border-radius: 0; 638 | border: 0 solid #1a1a1a 639 | } 640 | 641 | input[type=range]::-webkit-slider-thumb { 642 | box-shadow: 0 0 0 rgba(0, 0, 0, 0), 0 0 0 rgba(13, 13, 13, 0); 643 | border: 2px solid #000; 644 | height: 32px; 645 | width: 33px; 646 | border-radius: 32px; 647 | background: #fff; 648 | cursor: pointer; 649 | -webkit-appearance: none; 650 | margin-top: -14px 651 | } 652 | 653 | input[type=range]:focus::-webkit-slider-runnable-track { 654 | background: rgba(139, 139, 139, 0.5) 655 | } 656 | 657 | input[type=range]::-moz-range-track { 658 | width: 100%; 659 | height: 4px; 660 | cursor: pointer; 661 | box-shadow: 1px 1px 1px rgba(0, 0, 0, 0), 0 0 1px rgba(13, 13, 13, 0); 662 | background: rgba(80, 80, 80, 0.5); 663 | border-radius: 0; 664 | border: 0 solid #1a1a1a 665 | } 666 | 667 | input[type=range]::-moz-range-thumb { 668 | box-shadow: 0 0 0 rgba(0, 0, 0, 0), 0 0 0 rgba(13, 13, 13, 0); 669 | border: 2px solid #000; 670 | height: 32px; 671 | width: 33px; 672 | border-radius: 32px; 673 | background: #fff; 674 | cursor: pointer 675 | } 676 | 677 | input[type=range]::-ms-track { 678 | width: 100%; 679 | height: 4px; 680 | cursor: pointer; 681 | background: transparent; 682 | border-color: transparent; 683 | color: transparent 684 | } 685 | 686 | input[type=range]::-ms-fill-lower { 687 | background: rgba(21, 21, 21, 0.5); 688 | border: 0 solid #1a1a1a; 689 | border-radius: 0; 690 | box-shadow: 1px 1px 1px rgba(0, 0, 0, 0), 0 0 1px rgba(13, 13, 13, 0) 691 | } 692 | 693 | input[type=range]::-ms-fill-upper { 694 | background: rgba(80, 80, 80, 0.5); 695 | border: 0 solid #1a1a1a; 696 | border-radius: 0; 697 | box-shadow: 1px 1px 1px rgba(0, 0, 0, 0), 0 0 1px rgba(13, 13, 13, 0) 698 | } 699 | 700 | input[type=range]::-ms-thumb { 701 | box-shadow: 0 0 0 rgba(0, 0, 0, 0), 0 0 0 rgba(13, 13, 13, 0); 702 | border: 2px solid #000; 703 | height: 32px; 704 | width: 33px; 705 | border-radius: 32px; 706 | background: #fff; 707 | cursor: pointer; 708 | height: 4px 709 | } 710 | 711 | input[type=range]:focus::-ms-fill-lower { 712 | background: rgba(80, 80, 80, 0.5) 713 | } 714 | 715 | input[type=range]:focus::-ms-fill-upper { 716 | background: rgba(139, 139, 139, 0.5) 717 | } 718 | 719 | input[type=range]:focus::-webkit-slider-thumb, 720 | input[type=range]:focus::-moz-range-thumb, 721 | input[type=range]:focus::-ms-thumb { 722 | box-shadow: 2px 2px 0 #fd0 723 | } 724 | 725 | address { 726 | display: block; 727 | margin: var(--unit) 0 728 | } 729 | 730 | strong, 731 | b { 732 | font-weight: 700 733 | } 734 | 735 | em, 736 | i { 737 | font-style: italic 738 | } 739 | 740 | blockquote { 741 | font-size: 1.1875rem; 742 | padding: var(--unit); 743 | margin: var(--unit-2) 0; 744 | clear: both; 745 | border-left: 10px solid var(--color-line) 746 | } 747 | 748 | cite { 749 | color: var(--color-body-h4); 750 | opacity: .75; 751 | font-style: italic; 752 | padding: .5rem 0 753 | } 754 | 755 | blockquote q { 756 | font-size: 1.5rem 757 | } 758 | 759 | blockquote cite { 760 | display: block; 761 | font-size: 1rem 762 | } 763 | 764 | q:before { 765 | content: "“" 766 | } 767 | 768 | q:after { 769 | content: "”" 770 | } 771 | 772 | ins { 773 | color: var(--color-secondary) 774 | } 775 | 776 | del { 777 | color: var(--color-tertiary) 778 | } 779 | 780 | code { 781 | font-size: 1rem 782 | } 783 | 784 | kbd { 785 | font-size: 1rem; 786 | background: black; 787 | color: white; 788 | outline: 0.2em solid black; 789 | } 790 | 791 | mark { 792 | font-size: 1rem; 793 | background-color: var(--color-access); 794 | outline: 0.2em solid var(--color-access) 795 | } 796 | 797 | var { 798 | display: inline-block; 799 | color: #fff; 800 | background-color: var(--color-primary); 801 | border-radius: 0.1em; 802 | letter-spacing: 1px; 803 | text-decoration: none; 804 | text-transform: uppercase; 805 | font-style: normal; 806 | font-weight: 700; 807 | line-height: 1; 808 | padding: 0.1em 0.2em; 809 | } 810 | 811 | pre { 812 | max-width: 100%; 813 | display: block; 814 | overflow: auto; 815 | font-size: 1rem; 816 | border: 0; 817 | outline: 1px solid transparent; 818 | background-color: var(--color-very-light); 819 | margin: var(--unit) 0; 820 | padding: var(--unit); 821 | border: 1px solid var(--color-line) 822 | } 823 | 824 | samp { 825 | font-size: 1rem 826 | } 827 | 828 | dl { 829 | display: grid; 830 | grid-column-gap: var(--unit-2); 831 | grid-row-gap: var(--unit); 832 | grid-template-columns: [dt] max-content [dd] 1fr; 833 | margin: var(--unit) 0 var(--unit-2) 834 | } 835 | 836 | dt { 837 | font-weight: 700; 838 | grid-column-start: dt; 839 | grid-column-end: dt 840 | } 841 | 842 | dd { 843 | margin: 0; 844 | padding: 0; 845 | grid-column-start: dd; 846 | grid-column-end: dd 847 | } 848 | 849 | ul, 850 | ol { 851 | line-height: var(--leading); 852 | padding-left: 1.25rem; 853 | margin: var(--unit) 0 var(--unit-2); 854 | font-size: 1.1875rem 855 | } 856 | 857 | ul { 858 | list-style-type: disc; 859 | padding-left: 1.25rem 860 | } 861 | 862 | li { 863 | margin-bottom: .3125rem 864 | } 865 | 866 | summary, 867 | summary:hover { 868 | margin-left: -1.5rem; 869 | font-size: 1.1875rem; 870 | color: var(--color-primary); 871 | font-weight: 400; 872 | cursor: pointer; 873 | margin-bottom: 1rem 874 | } 875 | 876 | summary:hover { 877 | text-decoration: underline 878 | } 879 | 880 | summary:active { 881 | color: var(--color-primary-active); 882 | text-decoration: underline 883 | } 884 | 885 | summary:focus { 886 | outline: 0 887 | } 888 | 889 | details { 890 | position: relative; 891 | padding: 0 0 0 1.5625rem; 892 | margin-bottom: var(--unit) 893 | } 894 | 895 | details[open]:before { 896 | content: ''; 897 | border-left: 10px solid var(--color-line); 898 | position: absolute; 899 | top: 2.2rem; 900 | left: .125rem; 901 | bottom: -.625rem 902 | } 903 | 904 | table, 905 | table thead { 906 | border-collapse: collapse; 907 | border-radius: var(--border-radius); 908 | padding: 0 909 | } 910 | 911 | table { 912 | border: 1px solid var(--color-bg-secondary); 913 | border-spacing: 0; 914 | overflow-x: scroll; 915 | overflow-y: hidden; 916 | min-width: 100%; 917 | overflow: scroll; 918 | border: 0; 919 | width: 100%; 920 | table-layout: fixed 921 | } 922 | 923 | caption { 924 | padding: 0.5em 0; 925 | color: var(--color-body-light) 926 | } 927 | 928 | td, 929 | th, 930 | tr { 931 | padding: .4rem .8rem; 932 | text-align: var(--justify-important) 933 | } 934 | 935 | thead { 936 | background-color: var(--color); 937 | margin: 0; 938 | color: var(--color-text); 939 | background: 0 0; 940 | font-weight: 700 941 | } 942 | 943 | thead th:first-child { 944 | border-top-left-radius: var(--border-radius) 945 | } 946 | 947 | thead th:last-child { 948 | border-top-right-radius: var(--border-radius) 949 | } 950 | 951 | thead th:first-child, 952 | tr td:first-child { 953 | text-align: var(--justify-normal) 954 | } 955 | 956 | tr { 957 | border-bottom: 1px solid gray 958 | } 959 | 960 | tbody th { 961 | text-align: left 962 | } 963 | 964 | frame, 965 | frameset, 966 | iframe { 967 | border: 2px solid var(--color-body); 968 | margin: 0; 969 | width: 100%; 970 | height: auto 971 | } 972 | 973 | img, 974 | picture { 975 | display: block; 976 | max-width: 100%; 977 | height: auto 978 | } 979 | 980 | figure { 981 | margin: 0 0 var(--unit-2) 982 | } 983 | 984 | figcaption { 985 | padding: .5em 0; 986 | color: var(--color-light); 987 | font-size: 1rem 988 | } 989 | 990 | figcaption:before { 991 | transform: rotate(90deg) 992 | } 993 | 994 | audio, 995 | embed, 996 | object, 997 | video, 998 | iframe { 999 | width: 100%; 1000 | } 1001 | 1002 | progress, 1003 | meter { 1004 | margin: var(--unit) 0; 1005 | width: 100%; 1006 | height: var(--unit); 1007 | border: none; 1008 | border-radius: .25rem; 1009 | overflow: hidden; 1010 | background: var(--color-very-light); 1011 | display: block; 1012 | --background: var(--color-very-light); 1013 | --optimum: #228b22; 1014 | --sub-optimum: #ffd700; 1015 | --sub-sub-optimum: #dc143c 1016 | } 1017 | 1018 | progress[value]::-webkit-progress-bar { 1019 | background: var(--color-very-light) 1020 | } 1021 | 1022 | progress[value]::-webkit-progress-value { 1023 | background: var(--color-primary) 1024 | } 1025 | 1026 | meter::-webkit-meter-bar { 1027 | background: var(--color-very-light) 1028 | } 1029 | 1030 | meter:-moz-meter-optimum::-moz-meter-bar { 1031 | background: var(--optimum) 1032 | } 1033 | 1034 | meter::-webkit-meter-optimum-value { 1035 | background: var(--optimum) 1036 | } 1037 | 1038 | meter:-moz-meter-sub-optimum::-moz-meter-bar { 1039 | background: var(--sub-optimum) 1040 | } 1041 | 1042 | meter::-webkit-meter-suboptimum-value { 1043 | background: var(--sub-optimum) 1044 | } 1045 | 1046 | meter:-moz-meter-sub-sub-optimum::-moz-meter-bar { 1047 | background: var(--sub-sub-optimum) 1048 | } 1049 | 1050 | meter::-webkit-meter-even-less-good-value { 1051 | background: var(--sub-sub-optimum) 1052 | } 1053 | 1054 | body>footer { 1055 | margin: 0; 1056 | margin-top: var(--unit-3); 1057 | padding: var(--unit-3) 0; 1058 | border-top: 1px solid var(--color-line); 1059 | background: var(--color-very-light); 1060 | overflow: hidden 1061 | } 1062 | 1063 | body>footer a { 1064 | color: var(--color-body) 1065 | } 1066 | 1067 | @media all and (min-width: 640px) { 1068 | nav { 1069 | display: flex; 1070 | } 1071 | 1072 | nav>* { 1073 | flex-basis: 0; 1074 | flex-grow: 1; 1075 | flex-shrink: 1; 1076 | } 1077 | } 1078 | 1079 | @media all and (min-width: 960px) { 1080 | header+nav { 1081 | padding: 0; 1082 | } 1083 | } 1084 | 1085 | @media screen and (max-width:640px) { 1086 | table thead { 1087 | border: 0; 1088 | clip: rect(0 0 0 0); 1089 | height: 1px; 1090 | margin: -1px; 1091 | overflow: hidden; 1092 | padding: 0; 1093 | position: absolute; 1094 | width: 1px 1095 | } 1096 | 1097 | table tr { 1098 | display: block; 1099 | border: 0 1100 | } 1101 | 1102 | table td, 1103 | table th { 1104 | display: block; 1105 | text-align: right !important; 1106 | border-bottom: 1px solid 1107 | } 1108 | 1109 | table td::before { 1110 | content: attr(data-label); 1111 | float: left; 1112 | font-weight: 700; 1113 | text-transform: uppercase 1114 | } 1115 | } -------------------------------------------------------------------------------- /assets/css/phoenix.css: -------------------------------------------------------------------------------- 1 | /* Includes some default style for the starter application. 2 | * This can be safely deleted to start fresh. 3 | */ 4 | 5 | /* Milligram v1.4.1 https://milligram.github.io 6 | * Copyright (c) 2020 CJ Patoilo Licensed under the MIT license 7 | */ 8 | 9 | *,*:after,*:before{box-sizing:inherit}html{box-sizing:border-box;font-size:62.5%}body{color:#000000;font-family:'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;font-size:1.6em;font-weight:300;letter-spacing:.01em;line-height:1.6}blockquote{border-left:0.3rem solid #d1d1d1;margin-left:0;margin-right:0;padding:1rem 1.5rem}blockquote *:last-child{margin-bottom:0}.button,button,input[type='button'],input[type='reset'],input[type='submit']{background-color:#0069d9;border:0.1rem solid #0069d9;border-radius:.4rem;color:#fff;cursor:pointer;display:inline-block;font-size:1.1rem;font-weight:700;height:3.8rem;letter-spacing:.1rem;line-height:3.8rem;padding:0 3.0rem;text-align:center;text-decoration:none;text-transform:uppercase;white-space:nowrap}.button:focus,.button:hover,button:focus,button:hover,input[type='button']:focus,input[type='button']:hover,input[type='reset']:focus,input[type='reset']:hover,input[type='submit']:focus,input[type='submit']:hover{background-color:#606c76;border-color:#606c76;color:#fff;outline:0}.button[disabled],button[disabled],input[type='button'][disabled],input[type='reset'][disabled],input[type='submit'][disabled]{cursor:default;opacity:.5}.button[disabled]:focus,.button[disabled]:hover,button[disabled]:focus,button[disabled]:hover,input[type='button'][disabled]:focus,input[type='button'][disabled]:hover,input[type='reset'][disabled]:focus,input[type='reset'][disabled]:hover,input[type='submit'][disabled]:focus,input[type='submit'][disabled]:hover{background-color:#0069d9;border-color:#0069d9}.button.button-outline,button.button-outline,input[type='button'].button-outline,input[type='reset'].button-outline,input[type='submit'].button-outline{background-color:transparent;color:#0069d9}.button.button-outline:focus,.button.button-outline:hover,button.button-outline:focus,button.button-outline:hover,input[type='button'].button-outline:focus,input[type='button'].button-outline:hover,input[type='reset'].button-outline:focus,input[type='reset'].button-outline:hover,input[type='submit'].button-outline:focus,input[type='submit'].button-outline:hover{background-color:transparent;border-color:#606c76;color:#606c76}.button.button-outline[disabled]:focus,.button.button-outline[disabled]:hover,button.button-outline[disabled]:focus,button.button-outline[disabled]:hover,input[type='button'].button-outline[disabled]:focus,input[type='button'].button-outline[disabled]:hover,input[type='reset'].button-outline[disabled]:focus,input[type='reset'].button-outline[disabled]:hover,input[type='submit'].button-outline[disabled]:focus,input[type='submit'].button-outline[disabled]:hover{border-color:inherit;color:#0069d9}.button.button-clear,button.button-clear,input[type='button'].button-clear,input[type='reset'].button-clear,input[type='submit'].button-clear{background-color:transparent;border-color:transparent;color:#0069d9}.button.button-clear:focus,.button.button-clear:hover,button.button-clear:focus,button.button-clear:hover,input[type='button'].button-clear:focus,input[type='button'].button-clear:hover,input[type='reset'].button-clear:focus,input[type='reset'].button-clear:hover,input[type='submit'].button-clear:focus,input[type='submit'].button-clear:hover{background-color:transparent;border-color:transparent;color:#606c76}.button.button-clear[disabled]:focus,.button.button-clear[disabled]:hover,button.button-clear[disabled]:focus,button.button-clear[disabled]:hover,input[type='button'].button-clear[disabled]:focus,input[type='button'].button-clear[disabled]:hover,input[type='reset'].button-clear[disabled]:focus,input[type='reset'].button-clear[disabled]:hover,input[type='submit'].button-clear[disabled]:focus,input[type='submit'].button-clear[disabled]:hover{color:#0069d9}code{background:#f4f5f6;border-radius:.4rem;font-size:86%;margin:0 .2rem;padding:.2rem .5rem;white-space:nowrap}pre{background:#f4f5f6;border-left:0.3rem solid #0069d9;overflow-y:hidden}pre>code{border-radius:0;display:block;padding:1rem 1.5rem;white-space:pre}hr{border:0;border-top:0.1rem solid #f4f5f6;margin:3.0rem 0}input[type='color'],input[type='date'],input[type='datetime'],input[type='datetime-local'],input[type='email'],input[type='month'],input[type='number'],input[type='password'],input[type='search'],input[type='tel'],input[type='text'],input[type='url'],input[type='week'],input:not([type]),textarea,select{-webkit-appearance:none;background-color:transparent;border:0.1rem solid #d1d1d1;border-radius:.4rem;box-shadow:none;box-sizing:inherit;height:3.8rem;padding:.6rem 1.0rem .7rem;width:100%}input[type='color']:focus,input[type='date']:focus,input[type='datetime']:focus,input[type='datetime-local']:focus,input[type='email']:focus,input[type='month']:focus,input[type='number']:focus,input[type='password']:focus,input[type='search']:focus,input[type='tel']:focus,input[type='text']:focus,input[type='url']:focus,input[type='week']:focus,input:not([type]):focus,textarea:focus,select:focus{border-color:#0069d9;outline:0}select{background:url('data:image/svg+xml;utf8,') center right no-repeat;padding-right:3.0rem}select:focus{background-image:url('data:image/svg+xml;utf8,')}select[multiple]{background:none;height:auto}textarea{min-height:6.5rem}label,legend{display:block;font-size:1.6rem;font-weight:700;margin-bottom:.5rem}fieldset{border-width:0;padding:0}input[type='checkbox'],input[type='radio']{display:inline}.label-inline{display:inline-block;font-weight:normal;margin-left:.5rem}.container{margin:0 auto;max-width:112.0rem;padding:0 2.0rem;position:relative;width:100%}.row{display:flex;flex-direction:column;padding:0;width:100%}.row.row-no-padding{padding:0}.row.row-no-padding>.column{padding:0}.row.row-wrap{flex-wrap:wrap}.row.row-top{align-items:flex-start}.row.row-bottom{align-items:flex-end}.row.row-center{align-items:center}.row.row-stretch{align-items:stretch}.row.row-baseline{align-items:baseline}.row .column{display:block;flex:1 1 auto;margin-left:0;max-width:100%;width:100%}.row .column.column-offset-10{margin-left:10%}.row .column.column-offset-20{margin-left:20%}.row .column.column-offset-25{margin-left:25%}.row .column.column-offset-33,.row .column.column-offset-34{margin-left:33.3333%}.row .column.column-offset-40{margin-left:40%}.row .column.column-offset-50{margin-left:50%}.row .column.column-offset-60{margin-left:60%}.row .column.column-offset-66,.row .column.column-offset-67{margin-left:66.6666%}.row .column.column-offset-75{margin-left:75%}.row .column.column-offset-80{margin-left:80%}.row .column.column-offset-90{margin-left:90%}.row .column.column-10{flex:0 0 10%;max-width:10%}.row .column.column-20{flex:0 0 20%;max-width:20%}.row .column.column-25{flex:0 0 25%;max-width:25%}.row .column.column-33,.row .column.column-34{flex:0 0 33.3333%;max-width:33.3333%}.row .column.column-40{flex:0 0 40%;max-width:40%}.row .column.column-50{flex:0 0 50%;max-width:50%}.row .column.column-60{flex:0 0 60%;max-width:60%}.row .column.column-66,.row .column.column-67{flex:0 0 66.6666%;max-width:66.6666%}.row .column.column-75{flex:0 0 75%;max-width:75%}.row .column.column-80{flex:0 0 80%;max-width:80%}.row .column.column-90{flex:0 0 90%;max-width:90%}.row .column .column-top{align-self:flex-start}.row .column .column-bottom{align-self:flex-end}.row .column .column-center{align-self:center}@media (min-width: 40rem){.row{flex-direction:row;margin-left:-1.0rem;width:calc(100% + 2.0rem)}.row .column{margin-bottom:inherit;padding:0 1.0rem}}a{color:#0069d9;text-decoration:none}a:focus,a:hover{color:#606c76}dl,ol,ul{list-style:none;margin-top:0;padding-left:0}dl dl,dl ol,dl ul,ol dl,ol ol,ol ul,ul dl,ul ol,ul ul{font-size:90%;margin:1.5rem 0 1.5rem 3.0rem}ol{list-style:decimal inside}ul{list-style:circle inside}.button,button,dd,dt,li{margin-bottom:1.0rem}fieldset,input,select,textarea{margin-bottom:1.5rem}blockquote,dl,figure,form,ol,p,pre,table,ul{margin-bottom:2.5rem}table{border-spacing:0;display:block;overflow-x:auto;text-align:left;width:100%}td,th{border-bottom:0.1rem solid #e1e1e1;padding:1.2rem 1.5rem}td:first-child,th:first-child{padding-left:0}td:last-child,th:last-child{padding-right:0}@media (min-width: 40rem){table{display:table;overflow-x:initial}}b,strong{font-weight:bold}p{margin-top:0}h1,h2,h3,h4,h5,h6{font-weight:300;letter-spacing:-.1rem;margin-bottom:2.0rem;margin-top:0}h1{font-size:4.6rem;line-height:1.2}h2{font-size:3.6rem;line-height:1.25}h3{font-size:2.8rem;line-height:1.3}h4{font-size:2.2rem;letter-spacing:-.08rem;line-height:1.35}h5{font-size:1.8rem;letter-spacing:-.05rem;line-height:1.5}h6{font-size:1.6rem;letter-spacing:0;line-height:1.4}img{max-width:100%}.clearfix:after{clear:both;content:' ';display:table}.float-left{float:left}.float-right{float:right} 10 | 11 | /* General style */ 12 | h1{font-size: 3.6rem; line-height: 1.25} 13 | h2{font-size: 2.8rem; line-height: 1.3} 14 | h3{font-size: 2.2rem; letter-spacing: -.08rem; line-height: 1.35} 15 | h4{font-size: 1.8rem; letter-spacing: -.05rem; line-height: 1.5} 16 | h5{font-size: 1.6rem; letter-spacing: 0; line-height: 1.4} 17 | h6{font-size: 1.4rem; letter-spacing: 0; line-height: 1.2} 18 | pre{padding: 1em;} 19 | 20 | .container{ 21 | margin: 0 auto; 22 | max-width: 80.0rem; 23 | padding: 0 2.0rem; 24 | position: relative; 25 | width: 100% 26 | } 27 | select { 28 | width: auto; 29 | } 30 | 31 | /* Phoenix promo and logo */ 32 | .phx-hero { 33 | text-align: center; 34 | border-bottom: 1px solid #e3e3e3; 35 | background: #eee; 36 | border-radius: 6px; 37 | padding: 3em 3em 1em; 38 | margin-bottom: 3rem; 39 | font-weight: 200; 40 | font-size: 120%; 41 | } 42 | .phx-hero input { 43 | background: #ffffff; 44 | } 45 | .phx-logo { 46 | min-width: 300px; 47 | margin: 1rem; 48 | display: block; 49 | } 50 | .phx-logo img { 51 | width: auto; 52 | display: block; 53 | } 54 | 55 | /* Headers */ 56 | header { 57 | width: 100%; 58 | background: #fdfdfd; 59 | border-bottom: 1px solid #eaeaea; 60 | margin-bottom: 2rem; 61 | } 62 | header section { 63 | align-items: center; 64 | display: flex; 65 | flex-direction: column; 66 | justify-content: space-between; 67 | } 68 | header section :first-child { 69 | order: 2; 70 | } 71 | header section :last-child { 72 | order: 1; 73 | } 74 | header nav ul, 75 | header nav li { 76 | margin: 0; 77 | padding: 0; 78 | display: block; 79 | text-align: right; 80 | white-space: nowrap; 81 | } 82 | header nav ul { 83 | margin: 1rem; 84 | margin-top: 0; 85 | } 86 | header nav a { 87 | display: block; 88 | } 89 | 90 | @media (min-width: 40.0rem) { /* Small devices (landscape phones, 576px and up) */ 91 | header section { 92 | flex-direction: row; 93 | } 94 | header nav ul { 95 | margin: 1rem; 96 | } 97 | .phx-logo { 98 | flex-basis: 527px; 99 | margin: 2rem 1rem; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | // We import the CSS which is extracted to its own file by esbuild. 2 | // Remove this line if you add a your own CSS build pipeline (e.g postcss). 3 | import "../css/app.css" 4 | 5 | // If you want to use Phoenix channels, run `mix help phx.gen.channel` 6 | // to get started and then uncomment the line below. 7 | // import "./user_socket.js" 8 | 9 | // You can include dependencies in two ways. 10 | // 11 | // The simplest option is to put them in assets/vendor and 12 | // import them using relative paths: 13 | // 14 | // import "./vendor/some-package.js" 15 | // 16 | // Alternatively, you can `npm install some-package` and import 17 | // them using a path starting with the package name: 18 | // 19 | // import "some-package" 20 | // 21 | 22 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. 23 | import "phoenix_html" 24 | // Establish Phoenix Socket and LiveView configuration. 25 | import {Socket} from "phoenix" 26 | import {LiveSocket} from "phoenix_live_view" 27 | import topbar from "../vendor/topbar" 28 | 29 | import UpdatesChart from "./updates_chart"; 30 | let hooks = {}; 31 | hooks.UpdatesChart = UpdatesChart; 32 | 33 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") 34 | let liveSocket = new LiveSocket("/live", Socket, {hooks: hooks, params: {_csrf_token: csrfToken}}) 35 | 36 | // Show progress bar on live navigation and form submits 37 | topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) 38 | window.addEventListener("phx:page-loading-start", info => topbar.show()) 39 | window.addEventListener("phx:page-loading-stop", info => topbar.hide()) 40 | 41 | // connect if there are any LiveViews on the page 42 | liveSocket.connect() 43 | 44 | // expose liveSocket on window for web console debug logs and latency simulation: 45 | // >> liveSocket.enableDebug() 46 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 47 | // >> liveSocket.disableLatencySim() 48 | window.liveSocket = liveSocket 49 | -------------------------------------------------------------------------------- /assets/js/updates_chart.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | export default { 4 | loadChart(el, keys, values) { 5 | new Chart(el, { 6 | type: 'bar', 7 | data: { 8 | labels: keys, 9 | datasets: [{ 10 | label: '# of Published Updates', 11 | data: values, 12 | backgroundColor: [ 13 | '#505050', 14 | ], 15 | }] 16 | } 17 | }); 18 | }, 19 | 20 | mounted() { 21 | let parent = this; 22 | 23 | this.handleEvent("chart_data", ({keys, values}) => parent.loadChart(parent.el, keys, values)) 24 | }, 25 | } -------------------------------------------------------------------------------- /assets/vendor/topbar.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license MIT 3 | * topbar 1.0.0, 2021-01-06 4 | * http://buunguyen.github.io/topbar 5 | * Copyright (c) 2021 Buu Nguyen 6 | */ 7 | (function (window, document) { 8 | "use strict"; 9 | 10 | // https://gist.github.com/paulirish/1579671 11 | (function () { 12 | var lastTime = 0; 13 | var vendors = ["ms", "moz", "webkit", "o"]; 14 | for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { 15 | window.requestAnimationFrame = 16 | window[vendors[x] + "RequestAnimationFrame"]; 17 | window.cancelAnimationFrame = 18 | window[vendors[x] + "CancelAnimationFrame"] || 19 | window[vendors[x] + "CancelRequestAnimationFrame"]; 20 | } 21 | if (!window.requestAnimationFrame) 22 | window.requestAnimationFrame = function (callback, element) { 23 | var currTime = new Date().getTime(); 24 | var timeToCall = Math.max(0, 16 - (currTime - lastTime)); 25 | var id = window.setTimeout(function () { 26 | callback(currTime + timeToCall); 27 | }, timeToCall); 28 | lastTime = currTime + timeToCall; 29 | return id; 30 | }; 31 | if (!window.cancelAnimationFrame) 32 | window.cancelAnimationFrame = function (id) { 33 | clearTimeout(id); 34 | }; 35 | })(); 36 | 37 | var canvas, 38 | progressTimerId, 39 | fadeTimerId, 40 | currentProgress, 41 | showing, 42 | addEvent = function (elem, type, handler) { 43 | if (elem.addEventListener) elem.addEventListener(type, handler, false); 44 | else if (elem.attachEvent) elem.attachEvent("on" + type, handler); 45 | else elem["on" + type] = handler; 46 | }, 47 | options = { 48 | autoRun: true, 49 | barThickness: 3, 50 | barColors: { 51 | 0: "rgba(26, 188, 156, .9)", 52 | ".25": "rgba(52, 152, 219, .9)", 53 | ".50": "rgba(241, 196, 15, .9)", 54 | ".75": "rgba(230, 126, 34, .9)", 55 | "1.0": "rgba(211, 84, 0, .9)", 56 | }, 57 | shadowBlur: 10, 58 | shadowColor: "rgba(0, 0, 0, .6)", 59 | className: null, 60 | }, 61 | repaint = function () { 62 | canvas.width = window.innerWidth; 63 | canvas.height = options.barThickness * 5; // need space for shadow 64 | 65 | var ctx = canvas.getContext("2d"); 66 | ctx.shadowBlur = options.shadowBlur; 67 | ctx.shadowColor = options.shadowColor; 68 | 69 | var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); 70 | for (var stop in options.barColors) 71 | lineGradient.addColorStop(stop, options.barColors[stop]); 72 | ctx.lineWidth = options.barThickness; 73 | ctx.beginPath(); 74 | ctx.moveTo(0, options.barThickness / 2); 75 | ctx.lineTo( 76 | Math.ceil(currentProgress * canvas.width), 77 | options.barThickness / 2 78 | ); 79 | ctx.strokeStyle = lineGradient; 80 | ctx.stroke(); 81 | }, 82 | createCanvas = function () { 83 | canvas = document.createElement("canvas"); 84 | var style = canvas.style; 85 | style.position = "fixed"; 86 | style.top = style.left = style.right = style.margin = style.padding = 0; 87 | style.zIndex = 100001; 88 | style.display = "none"; 89 | if (options.className) canvas.classList.add(options.className); 90 | document.body.appendChild(canvas); 91 | addEvent(window, "resize", repaint); 92 | }, 93 | topbar = { 94 | config: function (opts) { 95 | for (var key in opts) 96 | if (options.hasOwnProperty(key)) options[key] = opts[key]; 97 | }, 98 | show: function () { 99 | if (showing) return; 100 | showing = true; 101 | if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); 102 | if (!canvas) createCanvas(); 103 | canvas.style.opacity = 1; 104 | canvas.style.display = "block"; 105 | topbar.progress(0); 106 | if (options.autoRun) { 107 | (function loop() { 108 | progressTimerId = window.requestAnimationFrame(loop); 109 | topbar.progress( 110 | "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) 111 | ); 112 | })(); 113 | } 114 | }, 115 | progress: function (to) { 116 | if (typeof to === "undefined") return currentProgress; 117 | if (typeof to === "string") { 118 | to = 119 | (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 120 | ? currentProgress 121 | : 0) + parseFloat(to); 122 | } 123 | currentProgress = to > 1 ? 1 : to; 124 | repaint(); 125 | return currentProgress; 126 | }, 127 | hide: function () { 128 | if (!showing) return; 129 | showing = false; 130 | if (progressTimerId != null) { 131 | window.cancelAnimationFrame(progressTimerId); 132 | progressTimerId = null; 133 | } 134 | (function loop() { 135 | if (topbar.progress("+.1") >= 1) { 136 | canvas.style.opacity -= 0.05; 137 | if (canvas.style.opacity <= 0.05) { 138 | canvas.style.display = "none"; 139 | fadeTimerId = null; 140 | return; 141 | } 142 | } 143 | fadeTimerId = window.requestAnimationFrame(loop); 144 | })(); 145 | }, 146 | }; 147 | 148 | if (typeof module === "object" && typeof module.exports === "object") { 149 | module.exports = topbar; 150 | } else if (typeof define === "function" && define.amd) { 151 | define(function () { 152 | return topbar; 153 | }); 154 | } else { 155 | this.topbar = topbar; 156 | } 157 | }.call(this, window, document)); 158 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | import Config 9 | 10 | config :websubhub, 11 | ecto_repos: [WebSubHub.Repo] 12 | 13 | config :websubhub, Oban, 14 | repo: WebSubHub.Repo, 15 | queues: [updates: 50] 16 | 17 | # Configures the endpoint 18 | config :websubhub, WebSubHubWeb.Endpoint, 19 | url: [host: "localhost"], 20 | render_errors: [view: WebSubHubWeb.ErrorView, accepts: ~w(html json), layout: false], 21 | pubsub_server: WebSubHub.PubSub, 22 | live_view: [signing_salt: "NziEB/R6"] 23 | 24 | # Configures the mailer 25 | # 26 | # By default it uses the "Local" adapter which stores the emails 27 | # locally. You can see the emails in your browser, at "/dev/mailbox". 28 | # 29 | # For production it's recommended to configure a different adapter 30 | # at the `config/runtime.exs`. 31 | config :websubhub, WebSubHub.Mailer, adapter: Swoosh.Adapters.Local 32 | 33 | # Swoosh API client is needed for adapters other than SMTP. 34 | config :swoosh, :api_client, false 35 | 36 | # Configure esbuild (the version is required) 37 | config :esbuild, 38 | version: "0.12.18", 39 | default: [ 40 | args: ~w(js/app.js --bundle --target=es2016 --outdir=../priv/static/assets), 41 | cd: Path.expand("../assets", __DIR__), 42 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 43 | ] 44 | 45 | # Configures Elixir's Logger 46 | config :logger, :console, 47 | format: "$time $metadata[$level] $message\n", 48 | metadata: [:request_id] 49 | 50 | # Use Jason for JSON parsing in Phoenix 51 | config :phoenix, :json_library, Jason 52 | 53 | config :mime, :types, %{ 54 | "application/x-www-form-urlencoded" => ["x-www-form-urlencoded"] 55 | } 56 | 57 | # Import environment specific config. This must remain at the bottom 58 | # of this file so it overrides the configuration defined above. 59 | import_config "#{config_env()}.exs" 60 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :websubhub, :environment, :dev 4 | 5 | # Configure your database 6 | config :websubhub, WebSubHub.Repo, 7 | username: "postgres", 8 | password: "postgres", 9 | database: "websubhub_dev", 10 | hostname: "localhost", 11 | show_sensitive_data_on_connection_error: true, 12 | pool_size: 10 13 | 14 | # For development, we disable any cache and enable 15 | # debugging and code reloading. 16 | # 17 | # The watchers configuration can be used to run external 18 | # watchers to your application. For example, we use it 19 | # with esbuild to bundle .js and .css sources. 20 | config :websubhub, WebSubHubWeb.Endpoint, 21 | # Binding to loopback ipv4 address prevents access from other machines. 22 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. 23 | http: [ip: {0, 0, 0, 0}, port: 4000], 24 | check_origin: false, 25 | code_reloader: true, 26 | debug_errors: true, 27 | secret_key_base: "AKMi80adwHRpK71g0mfqLKrYGDEQiucfMXYiNJdCTTs1Ro4Obhmcoh2W0NQt9VaG", 28 | watchers: [ 29 | # Start the esbuild watcher by calling Esbuild.install_and_run(:default, args) 30 | esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]} 31 | ] 32 | 33 | # ## SSL Support 34 | # 35 | # In order to use HTTPS in development, a self-signed 36 | # certificate can be generated by running the following 37 | # Mix task: 38 | # 39 | # mix phx.gen.cert 40 | # 41 | # Note that this task requires Erlang/OTP 20 or later. 42 | # Run `mix help phx.gen.cert` for more information. 43 | # 44 | # The `http:` config above can be replaced with: 45 | # 46 | # https: [ 47 | # port: 4001, 48 | # cipher_suite: :strong, 49 | # keyfile: "priv/cert/selfsigned_key.pem", 50 | # certfile: "priv/cert/selfsigned.pem" 51 | # ], 52 | # 53 | # If desired, both `http:` and `https:` keys can be 54 | # configured to run both http and https servers on 55 | # different ports. 56 | 57 | # Watch static and templates for browser reloading. 58 | config :websubhub, WebSubHubWeb.Endpoint, 59 | live_reload: [ 60 | patterns: [ 61 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", 62 | ~r"priv/gettext/.*(po)$", 63 | ~r"lib/websubhub_web/(live|views)/.*(ex)$", 64 | ~r"lib/websubhub_web/templates/.*(eex)$" 65 | ] 66 | ] 67 | 68 | # Do not include metadata nor timestamps in development logs 69 | config :logger, :console, format: "[$level] $message\n" 70 | 71 | # Set a higher stacktrace during development. Avoid configuring such 72 | # in production as building large stacktraces may be expensive. 73 | config :phoenix, :stacktrace_depth, 20 74 | 75 | # Initialize plugs at runtime for faster development compilation 76 | config :phoenix, :plug_init_mode, :runtime 77 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :websubhub, :environment, :prod 4 | 5 | # For production, don't forget to configure the url host 6 | # to something meaningful, Phoenix uses this information 7 | # when generating URLs. 8 | # 9 | # Note we also include the path to a cache manifest 10 | # containing the digested version of static files. This 11 | # manifest is generated by the `mix phx.digest` task, 12 | # which you should run after static files are built and 13 | # before starting your production server. 14 | config :websubhub, WebSubHubWeb.Endpoint, 15 | url: [host: "example.com", port: 80], 16 | cache_static_manifest: "priv/static/cache_manifest.json" 17 | 18 | # Do not print debug messages in production 19 | config :logger, level: :info 20 | 21 | # ## SSL Support 22 | # 23 | # To get SSL working, you will need to add the `https` key 24 | # to the previous section and set your `:url` port to 443: 25 | # 26 | # config :websubhub, WebSubHubWeb.Endpoint, 27 | # ..., 28 | # url: [host: "example.com", port: 443], 29 | # https: [ 30 | # ..., 31 | # port: 443, 32 | # cipher_suite: :strong, 33 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 34 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 35 | # ] 36 | # 37 | # The `cipher_suite` is set to `:strong` to support only the 38 | # latest and more secure SSL ciphers. This means old browsers 39 | # and clients may not be supported. You can set it to 40 | # `:compatible` for wider support. 41 | # 42 | # `:keyfile` and `:certfile` expect an absolute path to the key 43 | # and cert in disk or a relative path inside priv, for example 44 | # "priv/ssl/server.key". For all supported SSL configuration 45 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 46 | # 47 | # We also recommend setting `force_ssl` in your endpoint, ensuring 48 | # no data is ever sent via http, always redirecting to https: 49 | # 50 | # config :websubhub, WebSubHubWeb.Endpoint, 51 | # force_ssl: [hsts: true] 52 | # 53 | # Check `Plug.SSL` for all available options in `force_ssl`. 54 | -------------------------------------------------------------------------------- /config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # config/runtime.exs is executed for all environments, including 4 | # during releases. It is executed after compilation and before the 5 | # system starts, so it is typically used to load production configuration 6 | # and secrets from environment variables or elsewhere. Do not define 7 | # any compile-time configuration in here, as it won't be applied. 8 | # The block below contains prod specific runtime configuration. 9 | if config_env() == :prod do 10 | database_url = 11 | System.get_env("DATABASE_URL") || 12 | raise """ 13 | environment variable DATABASE_URL is missing. 14 | For example: ecto://USER:PASS@HOST/DATABASE 15 | """ 16 | 17 | config :websubhub, WebSubHub.Repo, 18 | # ssl: true, 19 | # socket_options: [:inet6], 20 | url: database_url, 21 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10") 22 | 23 | # The secret key base is used to sign/encrypt cookies and other secrets. 24 | # A default value is used in config/dev.exs and config/test.exs but you 25 | # want to use a different value for prod and you most likely don't want 26 | # to check this value into version control, so we use an environment 27 | # variable instead. 28 | secret_key_base = 29 | System.get_env("SECRET_KEY_BASE") || 30 | raise """ 31 | environment variable SECRET_KEY_BASE is missing. 32 | You can generate one by calling: mix phx.gen.secret 33 | """ 34 | 35 | config :websubhub, WebSubHubWeb.Endpoint, 36 | url: [host: "websubhub.com", port: System.get_env("PORT") || 4000], 37 | http: [ 38 | # Enable IPv6 and bind on all interfaces. 39 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. 40 | # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html 41 | # for details about using IPv6 vs IPv4 and loopback vs public addresses. 42 | ip: {0, 0, 0, 0, 0, 0, 0, 0}, 43 | port: String.to_integer(System.get_env("PORT") || "4000") 44 | ], 45 | secret_key_base: secret_key_base 46 | 47 | if System.get_env("START_SSL") do 48 | config :websubhub, WebSubHubWeb.Endpoint, 49 | https: [ 50 | port: 443, 51 | cipher_suite: :strong, 52 | otp_app: :websubhub, 53 | keyfile: System.get_env("SSL_KEYFILE_PATH"), 54 | certfile: System.get_env("SSL_CERTFILE_PATH"), 55 | cacertfile: System.get_env("SSL_CACERTFILE_PATH") 56 | ] 57 | end 58 | 59 | # ## Using releases 60 | # 61 | # If you are doing OTP releases, you need to instruct Phoenix 62 | # to start each relevant endpoint: 63 | # 64 | config :websubhub, WebSubHubWeb.Endpoint, server: true 65 | 66 | # Then you can assemble a release by calling `mix release`. 67 | # See `mix help release` for more information. 68 | 69 | # ## Configuring the mailer 70 | # 71 | # In production you need to configure the mailer to use a different adapter. 72 | # Also, you may need to configure the Swoosh API client of your choice if you 73 | # are not using SMTP. Here is an example of the configuration: 74 | # 75 | # config :websubhub, WebSubHub.Mailer, 76 | # adapter: Swoosh.Adapters.Mailgun, 77 | # api_key: System.get_env("MAILGUN_API_KEY"), 78 | # domain: System.get_env("MAILGUN_DOMAIN") 79 | # 80 | # For this example you need include a HTTP client required by Swoosh API client. 81 | # Swoosh supports Hackney and Finch out of the box: 82 | # 83 | # config :swoosh, :api_client, Swoosh.ApiClient.Hackney 84 | # 85 | # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details. 86 | end 87 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | # 5 | # The MIX_TEST_PARTITION environment variable can be used 6 | # to provide built-in test partitioning in CI environment. 7 | # Run `mix help test` for more information. 8 | config :websubhub, WebSubHub.Repo, 9 | username: "postgres", 10 | password: "postgres", 11 | database: "websubhub_test#{System.get_env("MIX_TEST_PARTITION")}", 12 | hostname: "localhost", 13 | pool: Ecto.Adapters.SQL.Sandbox, 14 | pool_size: 10 15 | 16 | # We don't run a server during test. If one is required, 17 | # you can enable the server option below. 18 | config :websubhub, WebSubHubWeb.Endpoint, 19 | http: [ip: {127, 0, 0, 1}, port: 4002], 20 | secret_key_base: "R0gaWdWHsMdrqB7jRAXMvxWkMNXD8v7lL6I9JjlMoEPmuoQbIckc5pdOom92UTX8", 21 | server: false 22 | 23 | # In test we don't send emails. 24 | config :websubhub, WebSubHub.Mailer, adapter: Swoosh.Adapters.Test 25 | 26 | config :websubhub, Oban, queues: false, plugins: false 27 | 28 | # Print only warnings and errors during test 29 | config :logger, level: :warn 30 | 31 | # Initialize plugs at runtime for faster test compilation 32 | config :phoenix, :plug_init_mode, :runtime 33 | -------------------------------------------------------------------------------- /lib/websubhub.ex: -------------------------------------------------------------------------------- 1 | defmodule WebSubHub do 2 | @moduledoc """ 3 | WebSubHub keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /lib/websubhub/application.ex: -------------------------------------------------------------------------------- 1 | defmodule WebSubHub.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | @impl true 9 | def start(_type, _args) do 10 | children = [ 11 | # Start the Ecto repository 12 | WebSubHub.Repo, 13 | # Start the Telemetry supervisor 14 | WebSubHubWeb.Telemetry, 15 | # Start the PubSub system 16 | {Phoenix.PubSub, name: WebSubHub.PubSub}, 17 | # Start the Endpoint (http/https) 18 | WebSubHubWeb.Endpoint, 19 | # Start a worker by calling: WebSubHub.Worker.start_link(arg) 20 | # {WebSubHub.Worker, arg} 21 | {Oban, oban_config()} 22 | ] 23 | 24 | # See https://hexdocs.pm/elixir/Supervisor.html 25 | # for other strategies and supported options 26 | opts = [strategy: :one_for_one, name: WebSubHub.Supervisor] 27 | Supervisor.start_link(children, opts) 28 | end 29 | 30 | # Tell Phoenix to update the endpoint configuration 31 | # whenever the application is updated. 32 | @impl true 33 | def config_change(changed, _new, removed) do 34 | WebSubHubWeb.Endpoint.config_change(changed, removed) 35 | :ok 36 | end 37 | 38 | # Conditionally disable queues or plugins here. 39 | defp oban_config do 40 | Application.fetch_env!(:websubhub, Oban) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/websubhub/jobs/dispatch_plain_update.ex: -------------------------------------------------------------------------------- 1 | defmodule WebSubHub.Jobs.DispatchPlainUpdate do 2 | use Oban.Worker, queue: :updates, max_attempts: 3 3 | require Logger 4 | 5 | alias WebSubHub.Updates 6 | 7 | @impl Oban.Worker 8 | def perform(%Oban.Job{ 9 | args: %{ 10 | "update_id" => update_id, 11 | "subscription_id" => subscription_id, 12 | "callback_url" => callback_url, 13 | "secret" => secret 14 | } 15 | }) do 16 | Logger.info("Sending #{update_id} to #{callback_url}") 17 | 18 | update = WebSubHub.Updates.get_update_and_topic(update_id) 19 | topic_url = update.topic.url 20 | 21 | links = [ 22 | "<#{topic_url}>; rel=self", 23 | "; rel=hub" 24 | ] 25 | 26 | headers = %{ 27 | "content-type" => update.content_type, 28 | "link" => Enum.join(links, ", ") 29 | } 30 | 31 | headers = 32 | if secret do 33 | hmac = :crypto.mac(:hmac, :sha256, secret, update.body) |> Base.encode16(case: :lower) 34 | Map.put(headers, "X-Hub-Signature", "sha256=" <> hmac) 35 | else 36 | headers 37 | end 38 | 39 | perform_request(callback_url, update.body, headers) 40 | |> log_request(update.id, subscription_id) 41 | end 42 | 43 | defp perform_request(callback_url, body, headers) do 44 | case HTTPoison.post(callback_url, body, headers) do 45 | {:ok, %HTTPoison.Response{status_code: code}} when code >= 200 and code < 300 -> 46 | Logger.info("Get OK response from #{callback_url}") 47 | 48 | {:ok, code} 49 | 50 | {:ok, %HTTPoison.Response{status_code: 410}} -> 51 | # Invalidate this subscription 52 | {:ok, 410} 53 | 54 | {:ok, %HTTPoison.Response{status_code: status_code}} -> 55 | {:failed, status_code} 56 | 57 | {:error, %HTTPoison.Error{reason: reason}} -> 58 | Logger.error("Get error response from #{callback_url}: #{reason}") 59 | {:error, reason} 60 | end 61 | end 62 | 63 | defp log_request(res, update_id, subscription_id) do 64 | status_code = 65 | case res do 66 | {_, code} when is_integer(code) -> 67 | code 68 | 69 | _ -> 70 | nil 71 | end 72 | 73 | Updates.create_subscription_update(update_id, subscription_id, status_code) 74 | 75 | res 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/websubhub/mailer.ex: -------------------------------------------------------------------------------- 1 | defmodule WebSubHub.Mailer do 2 | use Swoosh.Mailer, otp_app: :websubhub 3 | end 4 | -------------------------------------------------------------------------------- /lib/websubhub/release.ex: -------------------------------------------------------------------------------- 1 | defmodule WebSubHub.Release do 2 | @moduledoc """ 3 | Handles the migrations / loading required for running the app inside a release. 4 | """ 5 | 6 | @app :websubhub 7 | 8 | alias Ecto.Migrator 9 | 10 | def migrate do 11 | load_app() 12 | 13 | for repo <- repos() do 14 | {:ok, _, _} = Migrator.with_repo(repo, &Migrator.run(&1, :up, all: true)) 15 | end 16 | end 17 | 18 | def rollback(repo, version) do 19 | load_app() 20 | {:ok, _, _} = Migrator.with_repo(repo, &Migrator.run(&1, :down, to: version)) 21 | end 22 | 23 | defp repos do 24 | Application.fetch_env!(@app, :ecto_repos) 25 | end 26 | 27 | defp load_app do 28 | Application.load(@app) 29 | Application.ensure_all_started(:ssl) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/websubhub/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule WebSubHub.Repo do 2 | use Ecto.Repo, 3 | otp_app: :websubhub, 4 | adapter: Ecto.Adapters.Postgres 5 | end 6 | -------------------------------------------------------------------------------- /lib/websubhub/subscriptions.ex: -------------------------------------------------------------------------------- 1 | defmodule WebSubHub.Subscriptions do 2 | @moduledoc """ 3 | The Subscriptions context. 4 | """ 5 | require Logger 6 | 7 | import Ecto.Query, warn: false 8 | alias WebSubHub.Repo 9 | 10 | alias WebSubHub.Subscriptions.Topic 11 | alias WebSubHub.Subscriptions.Subscription 12 | 13 | def subscribe(topic_url, callback_url, lease_seconds \\ 864_000, secret \\ nil) do 14 | with {:ok, _} <- validate_url(topic_url), 15 | {:ok, callback_uri} <- validate_url(callback_url), 16 | {:ok, topic} <- find_or_create_topic(topic_url), 17 | {:ok, :success} <- validate_subscription(topic, callback_uri, lease_seconds) do 18 | case Repo.get_by(Subscription, topic_id: topic.id, callback_url: callback_url) do 19 | %Subscription{} = subscription -> 20 | lease_seconds = convert_lease_seconds(lease_seconds) 21 | expires_at = NaiveDateTime.add(NaiveDateTime.utc_now(), lease_seconds, :second) 22 | 23 | Logger.info("Subscriptions.subscribe: Updating #{topic_url} for #{callback_url}") 24 | 25 | subscription 26 | |> Subscription.changeset(%{ 27 | secret: secret, 28 | expires_at: expires_at, 29 | lease_seconds: lease_seconds 30 | }) 31 | |> Repo.update() 32 | 33 | nil -> 34 | create_subscription(topic, callback_uri, lease_seconds, secret) 35 | end 36 | else 37 | {:subscribe_validation_error, some_error} -> 38 | # If (and when) the subscription is denied, the hub MUST inform the subscriber by sending an HTTP [RFC7231] (or HTTPS [RFC2818]) GET request to the subscriber's callback URL as given in the subscription request. This request has the following query string arguments appended (format described in Section 4 of [URL]): 39 | {:ok, callback_uri} = validate_url(callback_url) 40 | 41 | reason = Atom.to_string(some_error) 42 | deny_subscription(callback_uri, topic_url, reason) 43 | 44 | Logger.info( 45 | "Subscriptions.subscribe: Failed validation for #{callback_url} with #{reason}" 46 | ) 47 | 48 | {:error, some_error} 49 | 50 | _ -> 51 | {:error, "something"} 52 | end 53 | end 54 | 55 | def unsubscribe(topic_url, callback_url) do 56 | with {:ok, _} <- validate_url(topic_url), 57 | {:ok, callback_uri} <- validate_url(callback_url), 58 | %Topic{} = topic <- get_topic_by_url(topic_url), 59 | %Subscription{} = subscription <- 60 | Repo.get_by(Subscription, topic_id: topic.id, callback_url: callback_url) do 61 | validate_unsubscribe(topic, callback_uri) 62 | 63 | Logger.info("Subscriptions.unsubscribe: Updating #{topic_url} for #{callback_url}") 64 | 65 | subscription 66 | |> Subscription.changeset(%{ 67 | expires_at: NaiveDateTime.utc_now() 68 | }) 69 | |> Repo.update() 70 | else 71 | _ -> {:error, :subscription_not_found} 72 | end 73 | end 74 | 75 | @doc """ 76 | Find or create a topic. 77 | 78 | Topics can exist without any valid subscriptions. Additionally a subscription can fail to validate and a topic still exist. 79 | 80 | ## Examples 81 | 82 | iex> find_or_create_topic("https://some-topic-url") 83 | {:ok, %Topic{}} 84 | """ 85 | def find_or_create_topic(topic_url) do 86 | case Repo.get_by(Topic, url: topic_url) do 87 | %Topic{} = topic -> 88 | {:ok, topic} 89 | 90 | nil -> 91 | %Topic{} 92 | |> Topic.changeset(%{ 93 | url: topic_url 94 | }) 95 | |> Repo.insert() 96 | end 97 | end 98 | 99 | def get_topic_by_url(topic_url) do 100 | Repo.get_by(Topic, url: topic_url) 101 | end 102 | 103 | def get_subscription_by_url(callback_url) do 104 | Repo.get_by(Subscription, callback_url: callback_url) 105 | end 106 | 107 | @doc """ 108 | Validate a subscription by sending a HTTP GET to the subscriber's callback_url. 109 | """ 110 | def validate_subscription( 111 | %Topic{} = topic, 112 | %URI{} = callback_uri, 113 | lease_seconds 114 | ) do 115 | challenge = :crypto.strong_rand_bytes(32) |> Base.url_encode64() |> binary_part(0, 32) 116 | 117 | params = %{ 118 | "hub.mode" => "subscribe", 119 | "hub.topic" => topic.url, 120 | "hub.challenge" => challenge, 121 | "hub.lease_seconds" => lease_seconds 122 | } 123 | 124 | callback_url = append_our_params(callback_uri, params) 125 | 126 | case HTTPoison.get(callback_url) do 127 | {:ok, %HTTPoison.Response{status_code: code, body: body}} when code >= 200 and code < 300 -> 128 | # Ensure the response body matches our challenge 129 | if challenge != String.trim(body) do 130 | {:subscribe_validation_error, :failed_challenge_body} 131 | else 132 | {:ok, :success} 133 | end 134 | 135 | {:ok, %HTTPoison.Response{status_code: 404}} -> 136 | {:subscribe_validation_error, :failed_404_response} 137 | 138 | {:ok, %HTTPoison.Response{}} -> 139 | {:subscribe_validation_error, :failed_unknown_response} 140 | 141 | {:error, %HTTPoison.Error{reason: reason}} -> 142 | Logger.error("Got unexpected error from validate subscription call: #{reason}") 143 | {:subscribe_validation_error, :failed_unknown_error} 144 | end 145 | end 146 | 147 | @doc """ 148 | Validate a unsubscription by sending a HTTP GET to the subscriber's callback_url. 149 | """ 150 | def validate_unsubscribe( 151 | %Topic{} = topic, 152 | %URI{} = callback_uri 153 | ) do 154 | challenge = :crypto.strong_rand_bytes(32) |> Base.url_encode64() |> binary_part(0, 32) 155 | 156 | params = %{ 157 | "hub.mode" => "unsubscribe", 158 | "hub.topic" => topic.url, 159 | "hub.challenge" => challenge 160 | } 161 | 162 | callback_url = append_our_params(callback_uri, params) 163 | 164 | case HTTPoison.get(callback_url) do 165 | {:ok, %HTTPoison.Response{}} -> 166 | {:ok, :success} 167 | 168 | {:error, %HTTPoison.Error{reason: reason}} -> 169 | Logger.error("Got unexpected error from validate unsubscribe call: #{reason}") 170 | {:unsubscribe_validation_error, :failed_unknown_error} 171 | end 172 | end 173 | 174 | def create_subscription(%Topic{} = topic, %URI{} = callback_uri, lease_seconds, secret) do 175 | lease_seconds = convert_lease_seconds(lease_seconds) 176 | expires_at = NaiveDateTime.add(NaiveDateTime.utc_now(), lease_seconds, :second) 177 | 178 | %Subscription{ 179 | topic: topic 180 | } 181 | |> Subscription.changeset(%{ 182 | callback_url: to_string(callback_uri), 183 | lease_seconds: lease_seconds, 184 | expires_at: expires_at, 185 | secret: secret 186 | }) 187 | |> Repo.insert() 188 | end 189 | 190 | defp convert_lease_seconds(seconds) when is_binary(seconds) do 191 | String.to_integer(seconds) 192 | end 193 | 194 | defp convert_lease_seconds(seconds), do: seconds 195 | 196 | def deny_subscription(%URI{} = callback_uri, topic_url, reason) do 197 | params = %{ 198 | "hub.mode" => "denied", 199 | "hub.topic" => topic_url, 200 | "hub.reason" => reason 201 | } 202 | 203 | final_url = append_our_params(callback_uri, params) 204 | 205 | # We don't especially care about a response on this one 206 | case HTTPoison.get(final_url) do 207 | {:ok, %HTTPoison.Response{}} -> 208 | {:ok, :success} 209 | 210 | {:error, %HTTPoison.Error{reason: _reason}} -> 211 | {:ok, :error} 212 | end 213 | end 214 | 215 | def list_active_topic_subscriptions(%Topic{} = topic) do 216 | now = NaiveDateTime.utc_now() 217 | 218 | Repo.all( 219 | from(s in Subscription, 220 | where: s.topic_id == ^topic.id and s.expires_at >= ^now 221 | ) 222 | ) 223 | end 224 | 225 | defp append_our_params(%URI{query: old_params} = uri, params) do 226 | query_addition = URI.encode_query(params) 227 | 228 | %{uri | query: merge_query_params(old_params, query_addition)} 229 | |> to_string() 230 | end 231 | 232 | defp merge_query_params(nil, new), do: new 233 | defp merge_query_params("", new), do: new 234 | defp merge_query_params(original, new), do: original <> "&" <> new 235 | 236 | defp validate_url(url) when is_binary(url) do 237 | case URI.new(url) do 238 | {:ok, uri} -> 239 | if uri.scheme in ["http", "https"] do 240 | {:ok, uri} 241 | else 242 | {:error, :url_not_http} 243 | end 244 | 245 | err -> 246 | err 247 | end 248 | end 249 | 250 | defp validate_url(_), do: {:error, :url_not_binary} 251 | 252 | def count_topics do 253 | Repo.one( 254 | from(u in Topic, 255 | select: count(u.id) 256 | ) 257 | ) 258 | end 259 | 260 | def count_active_subscriptions do 261 | now = NaiveDateTime.utc_now() 262 | 263 | Repo.one( 264 | from(s in Subscription, 265 | where: s.expires_at >= ^now, 266 | select: count(s.id) 267 | ) 268 | ) 269 | end 270 | 271 | def subscription_updates_chart do 272 | case Repo.query(""" 273 | select date(pushed_at) as "date", count(*) as "count" 274 | from subscription_updates 275 | group by date(pushed_at) 276 | order by date(pushed_at) desc 277 | limit 30; 278 | """) do 279 | {:ok, %Postgrex.Result{rows: rows}} -> 280 | flipped = Enum.reverse(rows) 281 | 282 | %{ 283 | keys: Enum.map(flipped, fn [key, _] -> key end), 284 | values: Enum.map(flipped, fn [_, value] -> value end) 285 | } 286 | 287 | _ -> 288 | %{keys: [], values: []} 289 | end 290 | end 291 | end 292 | -------------------------------------------------------------------------------- /lib/websubhub/subscriptions/subscription.ex: -------------------------------------------------------------------------------- 1 | defmodule WebSubHub.Subscriptions.Subscription do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | schema "subscriptions" do 6 | belongs_to :topic, WebSubHub.Subscriptions.Topic 7 | field :callback_url, :string 8 | field :lease_seconds, :float 9 | field :expires_at, :naive_datetime 10 | field :secret, :string 11 | 12 | timestamps() 13 | end 14 | 15 | @doc false 16 | def changeset(subscription, attrs) do 17 | subscription 18 | |> cast(attrs, [:callback_url, :lease_seconds, :expires_at, :secret]) 19 | |> validate_required([:callback_url, :lease_seconds, :expires_at]) 20 | |> unique_constraint([:topic_id, :callback_url]) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/websubhub/subscriptions/topic.ex: -------------------------------------------------------------------------------- 1 | defmodule WebSubHub.Subscriptions.Topic do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | schema "topics" do 6 | field :url, :string 7 | 8 | timestamps() 9 | end 10 | 11 | @doc false 12 | def changeset(topic, attrs) do 13 | topic 14 | |> cast(attrs, [:url]) 15 | |> validate_required([:url]) 16 | |> unique_constraint([:url]) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/websubhub/updates.ex: -------------------------------------------------------------------------------- 1 | defmodule WebSubHub.Updates do 2 | @moduledoc """ 3 | The Updates context. 4 | """ 5 | require Logger 6 | 7 | import Ecto.Query, warn: false 8 | alias WebSubHub.Repo 9 | 10 | alias WebSubHub.Subscriptions 11 | alias WebSubHub.Subscriptions.Topic 12 | 13 | alias WebSubHub.Updates.Update 14 | alias WebSubHub.Updates.SubscriptionUpdate 15 | 16 | def publish(topic_url) do 17 | case Subscriptions.get_topic_by_url(topic_url) do 18 | # Get all active subscriptions and publish the update to them 19 | %Subscriptions.Topic{} = topic -> 20 | case HTTPoison.get(topic.url) do 21 | {:ok, %HTTPoison.Response{status_code: code, body: body, headers: headers}} 22 | when code >= 200 and code < 300 -> 23 | {:ok, update} = create_update(topic, body, headers) 24 | 25 | # Realistically we should do all of this async, for now we'll do querying line and dispatch async 26 | subscribers = Subscriptions.list_active_topic_subscriptions(topic) 27 | 28 | Enum.each(subscribers, fn subscription -> 29 | Logger.debug("Queueing dispatch to #{subscription.callback_url}") 30 | 31 | WebSubHub.Jobs.DispatchPlainUpdate.new(%{ 32 | callback_url: subscription.callback_url, 33 | update_id: update.id, 34 | subscription_id: subscription.id, 35 | secret: subscription.secret 36 | }) 37 | |> Oban.insert() 38 | end) 39 | 40 | Logger.info("Updates.publish: Sending updates for #{topic_url}") 41 | {:ok, update} 42 | 43 | _ -> 44 | Logger.info("Updates.publish: Unsuccessful response code for #{topic.url}") 45 | {:error, "Publish URL did not return a successful status code."} 46 | end 47 | 48 | nil -> 49 | # Nothing found 50 | Logger.info("Updates.publish: Did not find topic for #{topic_url}") 51 | {:error, "Topic not found for topic URL."} 52 | 53 | err -> 54 | Logger.info("Updates.publish: Unknown error #{inspect(err)}") 55 | {:error, "Unknown error."} 56 | end 57 | end 58 | 59 | def create_update(%Topic{} = topic, body, headers) do 60 | %Update{ 61 | topic: topic 62 | } 63 | |> Update.changeset(%{ 64 | body: body, 65 | headers: headers, 66 | content_type: get_content_type_header(headers), 67 | links: get_link_headers(headers), 68 | hash: :crypto.hash(:sha256, body) |> Base.encode16(case: :lower) 69 | }) 70 | |> Repo.insert() 71 | end 72 | 73 | @doc """ 74 | Create a subscription update, uses ID's for quick insertion 75 | """ 76 | def create_subscription_update(update_id, subscription_id, status_code) 77 | when is_integer(update_id) and is_integer(subscription_id) do 78 | %SubscriptionUpdate{ 79 | update_id: update_id, 80 | subscription_id: subscription_id 81 | } 82 | |> SubscriptionUpdate.changeset(%{ 83 | pushed_at: NaiveDateTime.utc_now(), 84 | status_code: status_code 85 | }) 86 | |> Repo.insert() 87 | end 88 | 89 | def get_update(id) do 90 | Repo.get(Update, id) 91 | end 92 | 93 | def get_update_and_topic(id) do 94 | Repo.get(Update, id) |> Repo.preload(:topic) 95 | end 96 | 97 | def get_subscription_update(id) do 98 | Repo.get(SubscriptionUpdate, id) 99 | end 100 | 101 | defp get_content_type_header(headers) do 102 | content_type? = &(&1 in ["Content-Type", "content-type"]) 103 | found = for {k, v} <- headers, content_type?.(k), do: v 104 | 105 | case found do 106 | [content_type] -> 107 | content_type 108 | 109 | _ -> 110 | "application/octet-stream" 111 | end 112 | end 113 | 114 | defp get_link_headers(headers) do 115 | content_type? = &(&1 in ["Link", "link"]) 116 | for {k, v} <- headers, content_type?.(k), do: v 117 | end 118 | 119 | def count_30min_updates do 120 | now = NaiveDateTime.utc_now() 121 | time_ago = NaiveDateTime.add(now, -1800) 122 | 123 | Repo.one( 124 | from u in Update, 125 | where: u.inserted_at > ^time_ago and u.inserted_at < ^now, 126 | select: count(u.id) 127 | ) 128 | end 129 | 130 | def count_30min_subscription_updates do 131 | now = NaiveDateTime.utc_now() 132 | time_ago = NaiveDateTime.add(now, -1800) 133 | 134 | Repo.one( 135 | from u in SubscriptionUpdate, 136 | where: u.inserted_at > ^time_ago and u.inserted_at < ^now, 137 | select: count(u.id) 138 | ) 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /lib/websubhub/updates/headers.ex: -------------------------------------------------------------------------------- 1 | defmodule WebSubHub.Updates.Headers do 2 | @behaviour Ecto.Type 3 | def type, do: :binary 4 | 5 | # Provide our own casting rules. 6 | def cast(term) do 7 | {:ok, term} 8 | end 9 | 10 | def embed_as(_format) do 11 | :self 12 | end 13 | 14 | def equal?(a, b) do 15 | a == b 16 | end 17 | 18 | # When loading data from the database, we are guaranteed to 19 | # receive an integer (as databases are strict) and we will 20 | # just return it to be stored in the schema struct. 21 | def load(binary), do: {:ok, :erlang.binary_to_term(binary)} 22 | 23 | # When dumping data to the database, we *expect* an integer 24 | # but any value could be inserted into the struct, so we need 25 | # guard against them. 26 | def dump(term), do: {:ok, :erlang.term_to_binary(term)} 27 | end 28 | -------------------------------------------------------------------------------- /lib/websubhub/updates/subscription_update.ex: -------------------------------------------------------------------------------- 1 | defmodule WebSubHub.Updates.SubscriptionUpdate do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | schema "subscription_updates" do 6 | belongs_to :update, WebSubHub.Updates.Update 7 | belongs_to :subscription, WebSubHub.Subscriptions.Subscription 8 | 9 | field :pushed_at, :naive_datetime 10 | field :status_code, :integer 11 | 12 | timestamps() 13 | end 14 | 15 | @doc false 16 | def changeset(topic, attrs) do 17 | topic 18 | |> cast(attrs, [:pushed_at, :status_code]) 19 | |> validate_required([:pushed_at, :status_code]) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/websubhub/updates/update.ex: -------------------------------------------------------------------------------- 1 | defmodule WebSubHub.Updates.Update do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | schema "updates" do 6 | belongs_to :topic, WebSubHub.Subscriptions.Topic 7 | 8 | field :body, :binary 9 | field :headers, WebSubHub.Updates.Headers 10 | field :content_type, :string 11 | field :links, {:array, :string} 12 | field :hash, :string 13 | 14 | timestamps() 15 | end 16 | 17 | @doc false 18 | def changeset(topic, attrs) do 19 | topic 20 | |> cast(attrs, [:body, :headers, :content_type, :hash, :links]) 21 | |> validate_required([:body, :content_type, :hash, :links]) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/websubhub_web.ex: -------------------------------------------------------------------------------- 1 | defmodule WebSubHubWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, views, channels and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use WebSubHubWeb, :controller 9 | use WebSubHubWeb, :view 10 | 11 | The definitions below will be executed for every view, 12 | controller, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define any helper function in modules 17 | and import those modules here. 18 | """ 19 | 20 | def controller do 21 | quote do 22 | use Phoenix.Controller, namespace: WebSubHubWeb 23 | 24 | import Plug.Conn 25 | import WebSubHubWeb.Gettext 26 | alias WebSubHubWeb.Router.Helpers, as: Routes 27 | end 28 | end 29 | 30 | def view do 31 | quote do 32 | use Phoenix.View, 33 | root: "lib/websubhub_web/templates", 34 | namespace: WebSubHubWeb 35 | 36 | # Import convenience functions from controllers 37 | import Phoenix.Controller, 38 | only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] 39 | 40 | # Include shared imports and aliases for views 41 | unquote(view_helpers()) 42 | end 43 | end 44 | 45 | def live_view do 46 | quote do 47 | use Phoenix.LiveView, 48 | layout: {WebSubHubWeb.LayoutView, "live.html"} 49 | 50 | unquote(view_helpers()) 51 | end 52 | end 53 | 54 | def live_component do 55 | quote do 56 | use Phoenix.LiveComponent 57 | 58 | unquote(view_helpers()) 59 | end 60 | end 61 | 62 | def router do 63 | quote do 64 | use Phoenix.Router 65 | 66 | import Plug.Conn 67 | import Phoenix.Controller 68 | import Phoenix.LiveView.Router 69 | end 70 | end 71 | 72 | def channel do 73 | quote do 74 | use Phoenix.Channel 75 | import WebSubHubWeb.Gettext 76 | end 77 | end 78 | 79 | defp view_helpers do 80 | quote do 81 | # Use all HTML functionality (forms, tags, etc) 82 | use Phoenix.HTML 83 | 84 | # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc) 85 | import Phoenix.LiveView.Helpers 86 | 87 | # Import basic rendering functionality (render, render_layout, etc) 88 | import Phoenix.View 89 | 90 | import WebSubHubWeb.ErrorHelpers 91 | import WebSubHubWeb.Gettext 92 | alias WebSubHubWeb.Router.Helpers, as: Routes 93 | end 94 | end 95 | 96 | @doc """ 97 | When used, dispatch to the appropriate controller/view/etc. 98 | """ 99 | defmacro __using__(which) when is_atom(which) do 100 | apply(__MODULE__, which, []) 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/websubhub_web/controllers/hub_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule WebSubHubWeb.HubController do 2 | use WebSubHubWeb, :controller 3 | 4 | alias WebSubHub.Subscriptions 5 | alias WebSubHub.Updates 6 | 7 | def action(conn, _params) do 8 | conn 9 | |> handle_request(conn.params) 10 | end 11 | 12 | defp handle_request( 13 | conn, 14 | %{"hub.mode" => "subscribe", "hub.topic" => topic, "hub.callback" => callback} = params 15 | ) do 16 | lease_seconds = Map.get(params, "hub.lease_seconds", 864_000) 17 | secret = Map.get(params, "hub.secret") 18 | 19 | Subscriptions.subscribe(topic, callback, lease_seconds, secret) 20 | |> handle_response(conn) 21 | end 22 | 23 | defp handle_request(conn, %{ 24 | "hub.mode" => "unsubscribe", 25 | "hub.topic" => topic, 26 | "hub.callback" => callback 27 | }) do 28 | Subscriptions.unsubscribe(topic, callback) 29 | |> handle_response(conn) 30 | end 31 | 32 | defp handle_request(conn, %{"hub.mode" => "publish", "hub.topic" => topic}) do 33 | Updates.publish(topic) 34 | |> handle_response(conn) 35 | end 36 | 37 | defp handle_request(conn, %{"hub.mode" => "publish", "hub.url" => topic}) do 38 | # Compatability with https://pubsubhubbub.appspot.com/ 39 | Updates.publish(topic) 40 | |> handle_response(conn) 41 | end 42 | 43 | defp handle_response({:ok, _message}, conn) do 44 | conn 45 | |> Plug.Conn.send_resp(202, "") 46 | |> Plug.Conn.halt() 47 | end 48 | 49 | defp handle_response({:error, message}, conn) when is_binary(message) do 50 | conn 51 | |> Plug.Conn.send_resp(500, message) 52 | |> Plug.Conn.halt() 53 | end 54 | 55 | defp handle_response({:error, reason}, conn) when is_atom(reason) do 56 | [status_code, message] = 57 | case reason do 58 | :failed_challenge_body -> [403, "failed_challenge_body"] 59 | :failed_404_response -> [403, "failed_404_response"] 60 | :failed_unknown_response -> [403, "failed_unknown_response"] 61 | :failed_unknown_error -> [500, "failed_unknown_error"] 62 | _ -> [500, "failed_unknown_reason"] 63 | end 64 | 65 | conn 66 | |> Plug.Conn.send_resp(status_code, message) 67 | |> Plug.Conn.halt() 68 | end 69 | 70 | defp handle_response({:error, _}, conn) do 71 | conn 72 | |> Plug.Conn.send_resp(500, "unknown error") 73 | |> Plug.Conn.halt() 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/websubhub_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule WebSubHubWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :websubhub 3 | 4 | # The session will be stored in the cookie and signed, 5 | # this means its contents can be read but not tampered with. 6 | # Set :encryption_salt if you would also like to encrypt it. 7 | @session_options [ 8 | store: :cookie, 9 | key: "_websubhub_key", 10 | signing_salt: "QNBP8GYd" 11 | ] 12 | 13 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] 14 | 15 | # Serve at "/" the static files from "priv/static" directory. 16 | # 17 | # You should set gzip to true if you are running phx.digest 18 | # when deploying your static files in production. 19 | plug Plug.Static, 20 | at: "/", 21 | from: :websubhub, 22 | gzip: false, 23 | only: ~w(assets fonts images favicon.ico robots.txt .well-known) 24 | 25 | # Code reloading can be explicitly enabled under the 26 | # :code_reloader configuration of your endpoint. 27 | if code_reloading? do 28 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 29 | plug Phoenix.LiveReloader 30 | plug Phoenix.CodeReloader 31 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :websubhub 32 | end 33 | 34 | plug Phoenix.LiveDashboard.RequestLogger, 35 | param_key: "request_logger", 36 | cookie_key: "request_logger" 37 | 38 | plug Plug.RequestId 39 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 40 | 41 | plug Plug.Parsers, 42 | parsers: [:urlencoded, :multipart, :json], 43 | pass: ["*/*"], 44 | json_decoder: Phoenix.json_library() 45 | 46 | plug Plug.MethodOverride 47 | plug Plug.Head 48 | plug Plug.Session, @session_options 49 | plug WebSubHubWeb.Router 50 | end 51 | -------------------------------------------------------------------------------- /lib/websubhub_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule WebSubHubWeb.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](https://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import WebSubHubWeb.Gettext 9 | 10 | # Simple translation 11 | gettext("Here is the string to translate") 12 | 13 | # Plural translation 14 | ngettext("Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3) 17 | 18 | # Domain-based translation 19 | dgettext("errors", "Here is the error message to translate") 20 | 21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext, otp_app: :websubhub 24 | end 25 | -------------------------------------------------------------------------------- /lib/websubhub_web/live/index_page.ex: -------------------------------------------------------------------------------- 1 | defmodule WebSubHubWeb.Live.IndexPage do 2 | use WebSubHubWeb, :live_view 3 | end 4 | -------------------------------------------------------------------------------- /lib/websubhub_web/live/index_page.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Stable & Free WebSub Hub

4 |

Standards compliant open source WebSub Hub.

5 |
6 | 7 |

Usage

8 |

WebSubHub is a fully compliant WebSub Hub built that you can use to distribute live changes from various publishers. Usage of WebSubHub is very simple with only a single endpoint available at https://websubhub.com/hub.

9 |

Current implemented & supported version of WebSub is https://www.w3.org/TR/2018/REC-websub-20180123/, however older versions may work.

10 |

Fully tested against websub.rocks hub test suite.

11 | 12 |

Subscribing

13 |

You can subscribe to any public publishers using our hub. An example curl request is:

14 |
15 | $ curl \
16 |     -d "hub.mode=subscribe&hub.topic=$TOPIC_URL&hub.callback=$CALLBACK_URL" \
17 |     -X POST \
18 |     https://websubhub.com/hub
19 | 
20 |

Additionally, you may provide the following arguments:

21 |
22 |
hub.lease_seconds
23 |
Number of seconds for which the subscriber would like to have the subscription active, given as a positive decimal integer. Default value is 10 days.
24 |
hub.secret
25 |
A subscriber-provided cryptographically random unique secret string that will be used to compute a HMAC digest for the content distribution. This parameter MUST be less than 200 bytes in length.
26 |
27 |

Once you send the subscribe request, we'll send an appropriate GET request to your $CALLBACK_URL to confirm the subscription.

28 | 29 |

Unsubscribing

30 |

You can unsubscribe from a publisher by issuing a similar request, however with the hub.mode as unsubscribe. An example curl request is:

31 |
32 | $ curl \
33 |     -d "hub.mode=unsubscribe&hub.topic=$TOPIC_URL&hub.callback=$CALLBACK_URL" \
34 |     -X POST \
35 |     https://websubhub.com/hub
36 | 
37 | 38 |

Publishing

39 |

Though not specified in the specification, publishing with WebSubHub can be accompished using hub.mode set to publish with hub.topic containing the topic URL.

40 |

We also support hub.url for backwards compatibility with other services.

41 |
42 | $ curl \
43 |     -d "hub.mode=publish&hub.topic=$TOPIC_URL" \
44 |     -X POST \
45 |     https://websubhub.com/hub
46 | 
47 | 48 |

Support

49 |

If you're having issues with the service, or you otherwise need help, please open up an issue on GitHub! It's worth mentioning that you should not share private information in the public issue tracker.

50 | 51 |
-------------------------------------------------------------------------------- /lib/websubhub_web/live/status_page.ex: -------------------------------------------------------------------------------- 1 | defmodule WebSubHubWeb.Live.StatusPage do 2 | use WebSubHubWeb, :live_view 3 | 4 | alias WebSubHub.Updates 5 | alias WebSubHub.Subscriptions 6 | 7 | def render(assigns) do 8 | ~H""" 9 |
10 |

Status

11 | 12 |

Current:

13 | 17 | 18 |

Past 30 days:

19 | 20 | 21 |
22 | """ 23 | end 24 | 25 | def mount(_params, _, socket) do 26 | send(self(), :load_chart_data) 27 | 28 | {:ok, 29 | socket 30 | |> assign(:topics, Subscriptions.count_topics()) 31 | |> assign(:active_subscriptions, Subscriptions.count_active_subscriptions())} 32 | end 33 | 34 | def handle_info(:load_chart_data, socket) do 35 | {:noreply, 36 | socket 37 | |> push_event("chart_data", Subscriptions.subscription_updates_chart())} 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/websubhub_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule WebSubHubWeb.Router do 2 | use WebSubHubWeb, :router 3 | 4 | pipeline :browser do 5 | plug :accepts, ["html"] 6 | plug :fetch_session 7 | plug :fetch_live_flash 8 | plug :put_root_layout, {WebSubHubWeb.LayoutView, :root} 9 | plug :protect_from_forgery 10 | plug :put_secure_browser_headers 11 | end 12 | 13 | pipeline :api do 14 | plug :accepts, ["json"] 15 | end 16 | 17 | pipeline :websub_hub do 18 | plug :accepts, ["x-www-form-urlencoded"] 19 | end 20 | 21 | pipeline :admin do 22 | plug :auth 23 | end 24 | 25 | scope "/", WebSubHubWeb do 26 | pipe_through :browser 27 | 28 | live_session :default do 29 | live "/", Live.IndexPage 30 | live "/status", Live.StatusPage 31 | end 32 | end 33 | 34 | scope "/hub", WebSubHubWeb do 35 | pipe_through :websub_hub 36 | 37 | post "/", HubController, :action 38 | end 39 | 40 | # Other scopes may use custom stacks. 41 | # scope "/api", WebSubHubWeb do 42 | # pipe_through :api 43 | # end 44 | 45 | # Enables LiveDashboard only for development 46 | # 47 | # If you want to use the LiveDashboard in production, you should put 48 | # it behind authentication and allow only admins to access it. 49 | # If your application does not have an admins-only section yet, 50 | # you can use Plug.BasicAuth to set up some basic authentication 51 | # as long as you are also using SSL (which you should anyway). 52 | import Phoenix.LiveDashboard.Router 53 | 54 | scope "/" do 55 | pipe_through [:browser, :admin] 56 | 57 | live_dashboard "/dashboard", metrics: WebSubHubWeb.Telemetry 58 | end 59 | 60 | # Enables the Swoosh mailbox preview in development. 61 | # 62 | # Note that preview only shows emails that were sent by the same 63 | # node running the Phoenix server. 64 | if Mix.env() == :dev do 65 | scope "/dev" do 66 | pipe_through :browser 67 | 68 | forward "/mailbox", Plug.Swoosh.MailboxPreview 69 | end 70 | end 71 | 72 | defp auth(conn, _) do 73 | Plug.BasicAuth.basic_auth(conn, 74 | username: "admin", 75 | password: System.fetch_env!("ADMIN_PASSWORD") 76 | ) 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/websubhub_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule WebSubHubWeb.Telemetry do 2 | use Supervisor 3 | import Telemetry.Metrics 4 | 5 | def start_link(arg) do 6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 7 | end 8 | 9 | @impl true 10 | def init(_arg) do 11 | children = [ 12 | # Telemetry poller will execute the given period measurements 13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics 14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} 15 | # Add reporters as children of your supervision tree. 16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 17 | ] 18 | 19 | Supervisor.init(children, strategy: :one_for_one) 20 | end 21 | 22 | def metrics do 23 | [ 24 | # Phoenix Metrics 25 | summary("phoenix.endpoint.stop.duration", 26 | unit: {:native, :millisecond} 27 | ), 28 | summary("phoenix.router_dispatch.stop.duration", 29 | tags: [:route], 30 | unit: {:native, :millisecond} 31 | ), 32 | 33 | # Database Metrics 34 | summary("websubhub.repo.query.total_time", 35 | unit: {:native, :millisecond}, 36 | description: "The sum of the other measurements" 37 | ), 38 | summary("websubhub.repo.query.decode_time", 39 | unit: {:native, :millisecond}, 40 | description: "The time spent decoding the data received from the database" 41 | ), 42 | summary("websubhub.repo.query.query_time", 43 | unit: {:native, :millisecond}, 44 | description: "The time spent executing the query" 45 | ), 46 | summary("websubhub.repo.query.queue_time", 47 | unit: {:native, :millisecond}, 48 | description: "The time spent waiting for a database connection" 49 | ), 50 | summary("websubhub.repo.query.idle_time", 51 | unit: {:native, :millisecond}, 52 | description: 53 | "The time the connection spent waiting before being checked out for the query" 54 | ), 55 | 56 | # VM Metrics 57 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 58 | summary("vm.total_run_queue_lengths.total"), 59 | summary("vm.total_run_queue_lengths.cpu"), 60 | summary("vm.total_run_queue_lengths.io") 61 | ] 62 | end 63 | 64 | defp periodic_measurements do 65 | [ 66 | # A module, function and arguments to be invoked periodically. 67 | # This function must call :telemetry.execute/3 and a metric must be added above. 68 | # {WebSubHubWeb, :count_users, []} 69 | ] 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/websubhub_web/templates/layout/app.html.heex: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | <%= @inner_content %> 5 |
-------------------------------------------------------------------------------- /lib/websubhub_web/templates/layout/live.html.heex: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | <%= @inner_content %> 7 |
-------------------------------------------------------------------------------- /lib/websubhub_web/templates/layout/root.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= csrf_meta_tag() %> 9 | <%= live_title_tag assigns[:page_title] || "WebSubHub" %> 10 | 12 | 13 | 14 | 15 | 16 | 19 | 20 | <%= if Application.get_env(:websubhub, :environment) == :prod do %> 21 | 22 | <% end %> 23 | 24 | 25 | 26 | Skip to main content 27 | 28 | 29 |
30 | 39 |
40 | <%= @inner_content %> 41 | 58 |

Test

59 | 60 | 61 | -------------------------------------------------------------------------------- /lib/websubhub_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule WebSubHubWeb.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | use Phoenix.HTML 7 | 8 | @doc """ 9 | Generates tag for inlined form input errors. 10 | """ 11 | def error_tag(form, field) do 12 | Enum.map(Keyword.get_values(form.errors, field), fn error -> 13 | content_tag(:span, translate_error(error), 14 | class: "invalid-feedback", 15 | phx_feedback_for: input_name(form, field) 16 | ) 17 | end) 18 | end 19 | 20 | @doc """ 21 | Translates an error message using gettext. 22 | """ 23 | def translate_error({msg, opts}) do 24 | # When using gettext, we typically pass the strings we want 25 | # to translate as a static argument: 26 | # 27 | # # Translate "is invalid" in the "errors" domain 28 | # dgettext("errors", "is invalid") 29 | # 30 | # # Translate the number of files with plural rules 31 | # dngettext("errors", "1 file", "%{count} files", count) 32 | # 33 | # Because the error messages we show in our forms and APIs 34 | # are defined inside Ecto, we need to translate them dynamically. 35 | # This requires us to call the Gettext module passing our gettext 36 | # backend as first argument. 37 | # 38 | # Note we use the "errors" domain, which means translations 39 | # should be written to the errors.po file. The :count option is 40 | # set by Ecto and indicates we should also apply plural rules. 41 | if count = opts[:count] do 42 | Gettext.dngettext(WebSubHubWeb.Gettext, "errors", msg, msg, count, opts) 43 | else 44 | Gettext.dgettext(WebSubHubWeb.Gettext, "errors", msg, opts) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/websubhub_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule WebSubHubWeb.ErrorView do 2 | use WebSubHubWeb, :view 3 | 4 | # If you want to customize a particular status code 5 | # for a certain format, you may uncomment below. 6 | # def render("500.html", _assigns) do 7 | # "Internal Server Error" 8 | # end 9 | 10 | # By default, Phoenix returns the status message from 11 | # the template name. For example, "404.html" becomes 12 | # "Not Found". 13 | def template_not_found(template, _assigns) do 14 | Phoenix.Controller.status_message_from_template(template) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/websubhub_web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule WebSubHubWeb.LayoutView do 2 | use WebSubHubWeb, :view 3 | 4 | # Phoenix LiveDashboard is available only in development by default, 5 | # so we instruct Elixir to not warn if the dashboard route is missing. 6 | @compile {:no_warn_undefined, {Routes, :live_dashboard_path, 2}} 7 | end 8 | -------------------------------------------------------------------------------- /lib/websubhub_web/views/page_view.ex: -------------------------------------------------------------------------------- 1 | defmodule WebSubHubWeb.PageView do 2 | use WebSubHubWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule WebSubHub.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :websubhub, 7 | version: "0.1.0", 8 | elixir: "~> 1.12", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | compilers: [:gettext] ++ Mix.compilers(), 11 | start_permanent: Mix.env() == :prod, 12 | aliases: aliases(), 13 | deps: deps() 14 | ] 15 | end 16 | 17 | # Configuration for the OTP application. 18 | # 19 | # Type `mix help compile.app` for more information. 20 | def application do 21 | [ 22 | mod: {WebSubHub.Application, []}, 23 | extra_applications: [:logger, :runtime_tools, :os_mon] 24 | ] 25 | end 26 | 27 | # Specifies which paths to compile per environment. 28 | defp elixirc_paths(:test), do: ["lib", "test/support"] 29 | defp elixirc_paths(_), do: ["lib"] 30 | 31 | # Specifies your project dependencies. 32 | # 33 | # Type `mix help deps` for examples and options. 34 | defp deps do 35 | [ 36 | {:fake_server, "~> 2.1", only: :test}, 37 | {:floki, ">= 0.30.0", only: :test}, 38 | {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, 39 | {:esbuild, "~> 0.2", runtime: Mix.env() == :dev}, 40 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 41 | {:phoenix, "~> 1.6.0"}, 42 | {:phoenix_ecto, "~> 4.4"}, 43 | {:ecto_sql, "~> 3.6"}, 44 | {:postgrex, ">= 0.0.0"}, 45 | {:phoenix_html, "~> 3.0"}, 46 | {:phoenix_live_view, "~> 0.16.0"}, 47 | {:phoenix_live_dashboard, "~> 0.5"}, 48 | {:swoosh, "~> 1.3"}, 49 | {:telemetry_metrics, "~> 0.6"}, 50 | {:telemetry_poller, "~> 1.0"}, 51 | {:gettext, "~> 0.18"}, 52 | {:jason, "~> 1.2"}, 53 | {:plug_cowboy, "~> 2.5"}, 54 | {:httpoison, "~> 1.8"}, 55 | {:oban, "~> 2.10"}, 56 | {:ecto_psql_extras, "~> 0.6"} 57 | ] 58 | end 59 | 60 | # Aliases are shortcuts or tasks specific to the current project. 61 | # For example, to install project dependencies and perform other setup tasks, run: 62 | # 63 | # $ mix setup 64 | # 65 | # See the documentation for `Mix` for more info on aliases. 66 | defp aliases do 67 | [ 68 | setup: ["deps.get", "ecto.setup"], 69 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 70 | "ecto.reset": ["ecto.drop", "ecto.setup"], 71 | test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], 72 | "assets.deploy": ["esbuild default --minify", "phx.digest"], 73 | code_quality: ["format", "credo --strict"] 74 | ] 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, 3 | "castore": {:hex, :castore, "0.1.14", "3f6d7c7c1574c402fef29559d3f1a7389ba3524bc6a090a5e9e6abc3af65dcca", [:mix], [], "hexpm", "b34af542eadb727e6c8b37fdf73e18b2e02eb483a4ea0b52fd500bc23f052b7b"}, 4 | "certifi": {:hex, :certifi, "2.8.0", "d4fb0a6bb20b7c9c3643e22507e42f356ac090a1dcea9ab99e27e0376d695eba", [:rebar3], [], "hexpm", "6ac7efc1c6f8600b08d625292d4bbf584e14847ce1b6b5c44d983d273e1097ea"}, 5 | "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, 6 | "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, 7 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, 8 | "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, 9 | "credo": {:hex, :credo, "1.6.1", "7dc76dcdb764a4316c1596804c48eada9fff44bd4b733a91ccbf0c0f368be61e", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "698607fb5993720c7e93d2d8e76f2175bba024de964e160e2f7151ef3ab82ac5"}, 10 | "db_connection": {:hex, :db_connection, "2.4.1", "6411f6e23f1a8b68a82fa3a36366d4881f21f47fc79a9efb8c615e62050219da", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ea36d226ec5999781a9a8ad64e5d8c4454ecedc7a4d643e4832bf08efca01f00"}, 11 | "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, 12 | "ecto": {:hex, :ecto, "3.7.1", "a20598862351b29f80f285b21ec5297da1181c0442687f9b8329f0445d228892", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d36e5b39fc479e654cffd4dbe1865d9716e4a9b6311faff799b6f90ab81b8638"}, 13 | "ecto_psql_extras": {:hex, :ecto_psql_extras, "0.7.4", "5d43fd088d39a158c860b17e8d210669587f63ec89ea122a4654861c8c6e2db4", [:mix], [{:ecto_sql, "~> 3.4", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.15.7", [hex: :postgrex, repo: "hexpm", optional: false]}, {:table_rex, "~> 3.1.1", [hex: :table_rex, repo: "hexpm", optional: false]}], "hexpm", "311db02f1b772e3d0dc7f56a05044b5e1499d78ed6abf38885e1ca70059449e5"}, 14 | "ecto_sql": {:hex, :ecto_sql, "3.7.1", "8de624ef50b2a8540252d8c60506379fbbc2707be1606853df371cf53df5d053", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.4.0 or ~> 0.5.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2b42a32e2ce92f64aba5c88617891ab3b0ba34f3f3a503fa20009eae1a401c81"}, 15 | "esbuild": {:hex, :esbuild, "0.4.0", "9f17db148aead4cf1e6e6a584214357287a93407b5fb51a031f122b61385d4c2", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "b61e4e6b92ffe45e4ee4755a22de6211a67c67987dc02afb35a425a0add1d447"}, 16 | "fake_server": {:hex, :fake_server, "2.1.0", "aefed08a587e2498fdb39ac9de6f9eabbe7bd83da9801d08d3574d61b7eb03d5", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm", "3200d57a523b27d2c8ebfc1a80b76697b3c8a06bf9d678d82114f5f98d350c75"}, 17 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 18 | "floki": {:hex, :floki, "0.32.0", "f915dc15258bc997d49be1f5ef7d3992f8834d6f5695270acad17b41f5bcc8e2", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "1c5a91cae1fd8931c26a4826b5e2372c284813904c8bacb468b5de39c7ececbd"}, 19 | "gettext": {:hex, :gettext, "0.19.0", "6909d61b38bb33339558f128f8af5913d5d5fe304a770217bf352b1620fb7ec4", [:mix], [], "hexpm", "3f7a274f52ebda9bb6655dfeda3d6b0dc4537ae51ce41dcccc7f73ca7379ad5e"}, 20 | "hackney": {:hex, :hackney, "1.18.0", "c4443d960bb9fba6d01161d01cd81173089686717d9490e5d3606644c48d121f", [:rebar3], [{:certifi, "~>2.8.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "9afcda620704d720db8c6a3123e9848d09c87586dc1c10479c42627b905b5c5e"}, 21 | "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, 22 | "httpoison": {:hex, :httpoison, "1.8.0", "6b85dea15820b7804ef607ff78406ab449dd78bed923a49c7160e1886e987a3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "28089eaa98cf90c66265b6b5ad87c59a3729bea2e74e9d08f9b51eb9729b3c3a"}, 23 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 24 | "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, 25 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 26 | "mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"}, 27 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 28 | "oban": {:hex, :oban, "2.10.1", "202a90f2aed0130b7d750bdbfea8090c8321bce255bade10fd3699733565add0", [:mix], [{:ecto_sql, "~> 3.6", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "161cdd01194147cd6a3efdb1d6c3d9689309991412f799c1e242c18912e307c3"}, 29 | "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, 30 | "phoenix": {:hex, :phoenix, "1.6.6", "281c8ce8dccc9f60607346b72cdfc597c3dde134dd9df28dff08282f0b751754", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "807bd646e64cd9dc83db016199715faba72758e6db1de0707eef0a2da4924364"}, 31 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"}, 32 | "phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"}, 33 | "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.5.3", "ff153c46aee237dd7244f07e9b98d557fe0d1de7a5916438e634c3be2d13c607", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.16.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "e36e62b1f61c19b645853af78290a5e7900f7cae1e676714ff69f9836e2f2e76"}, 34 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.3.3", "3a53772a6118d5679bf50fc1670505a290e32a1d195df9e069d8c53ab040c054", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "766796676e5f558dbae5d1bdb066849673e956005e3730dfd5affd7a6da4abac"}, 35 | "phoenix_live_view": {:hex, :phoenix_live_view, "0.16.4", "5692edd0bac247a9a816eee7394e32e7a764959c7d0cf9190662fc8b0cd24c97", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.9 or ~> 1.6.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "754ba49aa2e8601afd4f151492c93eb72df69b0b9856bab17711b8397e43bba0"}, 36 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"}, 37 | "phoenix_view": {:hex, :phoenix_view, "1.1.0", "149f053830ec3c19a2a8a67c208885a26e4c2b92cc4a9d54e03b633d68ef9add", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "dd219f768b3d97a224ed11e8a83f4fd0f3bd490434d3950d7c51a2e597a762f1"}, 38 | "plug": {:hex, :plug, "1.12.1", "645678c800601d8d9f27ad1aebba1fdb9ce5b2623ddb961a074da0b96c35187d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d57e799a777bc20494b784966dc5fbda91eb4a09f571f76545b72a634ce0d30b"}, 39 | "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"}, 40 | "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, 41 | "poison": {:hex, :poison, "5.0.0", "d2b54589ab4157bbb82ec2050757779bfed724463a544b6e20d79855a9e43b24", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "11dc6117c501b80c62a7594f941d043982a1bd05a1184280c0d9166eb4d8d3fc"}, 42 | "postgrex": {:hex, :postgrex, "0.15.13", "7794e697481799aee8982688c261901de493eb64451feee6ea58207d7266d54a", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "3ffb76e1a97cfefe5c6a95632a27ffb67f28871c9741fb585f9d1c3cd2af70f1"}, 43 | "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, 44 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 45 | "swoosh": {:hex, :swoosh, "1.5.2", "c246e0038367bf9ac3b66715151930a7215eb7427c242cc5206fc59fa344a7dc", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc34b2c14afaa6e2cd92c829492536887a00ae625e404e40469926949b029605"}, 46 | "table_rex": {:hex, :table_rex, "3.1.1", "0c67164d1714b5e806d5067c1e96ff098ba7ae79413cc075973e17c38a587caa", [:mix], [], "hexpm", "678a23aba4d670419c23c17790f9dcd635a4a89022040df7d5d772cb21012490"}, 47 | "telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"}, 48 | "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, 49 | "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, 50 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 51 | } 52 | -------------------------------------------------------------------------------- /priv/gettext/en/LC_MESSAGES/errors.po: -------------------------------------------------------------------------------- 1 | ## `msgid`s in this file come from POT (.pot) files. 2 | ## 3 | ## Do not add, change, or remove `msgid`s manually here as 4 | ## they're tied to the ones in the corresponding POT file 5 | ## (with the same domain). 6 | ## 7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge` 8 | ## to merge POT files into PO files. 9 | msgid "" 10 | msgstr "" 11 | "Language: en\n" 12 | 13 | ## From Ecto.Changeset.cast/4 14 | msgid "can't be blank" 15 | msgstr "" 16 | 17 | ## From Ecto.Changeset.unique_constraint/3 18 | msgid "has already been taken" 19 | msgstr "" 20 | 21 | ## From Ecto.Changeset.put_change/3 22 | msgid "is invalid" 23 | msgstr "" 24 | 25 | ## From Ecto.Changeset.validate_acceptance/3 26 | msgid "must be accepted" 27 | msgstr "" 28 | 29 | ## From Ecto.Changeset.validate_format/3 30 | msgid "has invalid format" 31 | msgstr "" 32 | 33 | ## From Ecto.Changeset.validate_subset/3 34 | msgid "has an invalid entry" 35 | msgstr "" 36 | 37 | ## From Ecto.Changeset.validate_exclusion/3 38 | msgid "is reserved" 39 | msgstr "" 40 | 41 | ## From Ecto.Changeset.validate_confirmation/3 42 | msgid "does not match confirmation" 43 | msgstr "" 44 | 45 | ## From Ecto.Changeset.no_assoc_constraint/3 46 | msgid "is still associated with this entry" 47 | msgstr "" 48 | 49 | msgid "are still associated with this entry" 50 | msgstr "" 51 | 52 | ## From Ecto.Changeset.validate_length/3 53 | msgid "should have %{count} item(s)" 54 | msgid_plural "should have %{count} item(s)" 55 | msgstr[0] "" 56 | msgstr[1] "" 57 | 58 | msgid "should be %{count} character(s)" 59 | msgid_plural "should be %{count} character(s)" 60 | msgstr[0] "" 61 | msgstr[1] "" 62 | 63 | msgid "should be %{count} byte(s)" 64 | msgid_plural "should be %{count} byte(s)" 65 | msgstr[0] "" 66 | msgstr[1] "" 67 | 68 | msgid "should have at least %{count} item(s)" 69 | msgid_plural "should have at least %{count} item(s)" 70 | msgstr[0] "" 71 | msgstr[1] "" 72 | 73 | msgid "should be at least %{count} character(s)" 74 | msgid_plural "should be at least %{count} character(s)" 75 | msgstr[0] "" 76 | msgstr[1] "" 77 | 78 | msgid "should be at least %{count} byte(s)" 79 | msgid_plural "should be at least %{count} byte(s)" 80 | msgstr[0] "" 81 | msgstr[1] "" 82 | 83 | msgid "should have at most %{count} item(s)" 84 | msgid_plural "should have at most %{count} item(s)" 85 | msgstr[0] "" 86 | msgstr[1] "" 87 | 88 | msgid "should be at most %{count} character(s)" 89 | msgid_plural "should be at most %{count} character(s)" 90 | msgstr[0] "" 91 | msgstr[1] "" 92 | 93 | msgid "should be at most %{count} byte(s)" 94 | msgid_plural "should be at most %{count} byte(s)" 95 | msgstr[0] "" 96 | msgstr[1] "" 97 | 98 | ## From Ecto.Changeset.validate_number/3 99 | msgid "must be less than %{number}" 100 | msgstr "" 101 | 102 | msgid "must be greater than %{number}" 103 | msgstr "" 104 | 105 | msgid "must be less than or equal to %{number}" 106 | msgstr "" 107 | 108 | msgid "must be greater than or equal to %{number}" 109 | msgstr "" 110 | 111 | msgid "must be equal to %{number}" 112 | msgstr "" 113 | -------------------------------------------------------------------------------- /priv/gettext/errors.pot: -------------------------------------------------------------------------------- 1 | ## This is a PO Template file. 2 | ## 3 | ## `msgid`s here are often extracted from source code. 4 | ## Add new translations manually only if they're dynamic 5 | ## translations that can't be statically extracted. 6 | ## 7 | ## Run `mix gettext.extract` to bring this file up to 8 | ## date. Leave `msgstr`s empty as changing them here has no 9 | ## effect: edit them in PO (`.po`) files instead. 10 | 11 | ## From Ecto.Changeset.cast/4 12 | msgid "can't be blank" 13 | msgstr "" 14 | 15 | ## From Ecto.Changeset.unique_constraint/3 16 | msgid "has already been taken" 17 | msgstr "" 18 | 19 | ## From Ecto.Changeset.put_change/3 20 | msgid "is invalid" 21 | msgstr "" 22 | 23 | ## From Ecto.Changeset.validate_acceptance/3 24 | msgid "must be accepted" 25 | msgstr "" 26 | 27 | ## From Ecto.Changeset.validate_format/3 28 | msgid "has invalid format" 29 | msgstr "" 30 | 31 | ## From Ecto.Changeset.validate_subset/3 32 | msgid "has an invalid entry" 33 | msgstr "" 34 | 35 | ## From Ecto.Changeset.validate_exclusion/3 36 | msgid "is reserved" 37 | msgstr "" 38 | 39 | ## From Ecto.Changeset.validate_confirmation/3 40 | msgid "does not match confirmation" 41 | msgstr "" 42 | 43 | ## From Ecto.Changeset.no_assoc_constraint/3 44 | msgid "is still associated with this entry" 45 | msgstr "" 46 | 47 | msgid "are still associated with this entry" 48 | msgstr "" 49 | 50 | ## From Ecto.Changeset.validate_length/3 51 | msgid "should be %{count} character(s)" 52 | msgid_plural "should be %{count} character(s)" 53 | msgstr[0] "" 54 | msgstr[1] "" 55 | 56 | msgid "should have %{count} item(s)" 57 | msgid_plural "should have %{count} item(s)" 58 | msgstr[0] "" 59 | msgstr[1] "" 60 | 61 | msgid "should be at least %{count} character(s)" 62 | msgid_plural "should be at least %{count} character(s)" 63 | msgstr[0] "" 64 | msgstr[1] "" 65 | 66 | msgid "should have at least %{count} item(s)" 67 | msgid_plural "should have at least %{count} item(s)" 68 | msgstr[0] "" 69 | msgstr[1] "" 70 | 71 | msgid "should be at most %{count} character(s)" 72 | msgid_plural "should be at most %{count} character(s)" 73 | msgstr[0] "" 74 | msgstr[1] "" 75 | 76 | msgid "should have at most %{count} item(s)" 77 | msgid_plural "should have at most %{count} item(s)" 78 | msgstr[0] "" 79 | msgstr[1] "" 80 | 81 | ## From Ecto.Changeset.validate_number/3 82 | msgid "must be less than %{number}" 83 | msgstr "" 84 | 85 | msgid "must be greater than %{number}" 86 | msgstr "" 87 | 88 | msgid "must be less than or equal to %{number}" 89 | msgstr "" 90 | 91 | msgid "must be greater than or equal to %{number}" 92 | msgstr "" 93 | 94 | msgid "must be equal to %{number}" 95 | msgstr "" 96 | -------------------------------------------------------------------------------- /priv/repo/migrations/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto_sql], 3 | inputs: ["*.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /priv/repo/migrations/20220108154834_create_topics.exs: -------------------------------------------------------------------------------- 1 | defmodule WebSubHub.Repo.Migrations.CreateTopics do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:topics) do 6 | add :url, :string 7 | 8 | timestamps() 9 | end 10 | 11 | create unique_index(:topics, [:url]) 12 | 13 | create table(:subscriptions) do 14 | add :topic_id, references(:topics) 15 | add :callback_url, :string 16 | add :lease_seconds, :float 17 | add :expires_at, :naive_datetime 18 | add :secret, :string, nullable: true 19 | 20 | timestamps() 21 | end 22 | 23 | create table(:updates) do 24 | add :topic_id, references(:topics) 25 | 26 | add :body, :binary 27 | add :headers, :binary 28 | add :content_type, :text 29 | add :links, {:array, :text} 30 | add :hash, :string 31 | 32 | timestamps() 33 | end 34 | 35 | create table(:subscription_updates) do 36 | add :update_id, references(:updates) 37 | add :subscription_id, references(:subscriptions) 38 | add :pushed_at, :naive_datetime 39 | add :status_code, :integer, nullable: true 40 | 41 | timestamps() 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /priv/repo/migrations/20220108233614_add_oban_jobs_table.exs: -------------------------------------------------------------------------------- 1 | defmodule WebSubHub.Repo.Migrations.AddObanJobsTable do 2 | use Ecto.Migration 3 | 4 | def up do 5 | Oban.Migrations.up() 6 | end 7 | 8 | # We specify `version: 1` in `down`, ensuring that we'll roll all the way back down if 9 | # necessary, regardless of which version we've migrated `up` to. 10 | def down do 11 | Oban.Migrations.down(version: 1) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /priv/repo/migrations/20220126190939_add_unique_constaint_to_subscriptions.exs: -------------------------------------------------------------------------------- 1 | defmodule WebSubHub.Repo.Migrations.AddUniqueConstaintToSubscriptions do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create unique_index(:subscriptions, [:topic_id, :callback_url]) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /priv/repo/migrations/20220126195116_longer_everything.exs: -------------------------------------------------------------------------------- 1 | defmodule WebSubHub.Repo.Migrations.LongerEverything do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:topics) do 6 | modify :url, :text 7 | end 8 | 9 | alter table(:subscriptions) do 10 | modify :callback_url, :text 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | # Script for populating the database. You can run it as: 2 | # 3 | # mix run priv/repo/seeds.exs 4 | # 5 | # Inside the script, you can read and write to any of your 6 | # repositories directly: 7 | # 8 | # WebSubHub.Repo.insert!(%WebSubHub.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | -------------------------------------------------------------------------------- /priv/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clone1018/WebSubHub/ecf3845af687186bb6a57bca8662b1557b51ead5/priv/static/favicon.ico -------------------------------------------------------------------------------- /priv/static/images/phoenix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clone1018/WebSubHub/ecf3845af687186bb6a57bca8662b1557b51ead5/priv/static/images/phoenix.png -------------------------------------------------------------------------------- /priv/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule WebSubHubWeb.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use WebSubHubWeb.ChannelCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with channels 23 | import Phoenix.ChannelTest 24 | import WebSubHubWeb.ChannelCase 25 | 26 | # The default endpoint for testing 27 | @endpoint WebSubHubWeb.Endpoint 28 | end 29 | end 30 | 31 | setup tags do 32 | pid = Ecto.Adapters.SQL.Sandbox.start_owner!(WebSubHub.Repo, shared: not tags[:async]) 33 | on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) 34 | :ok 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule WebSubHubWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use WebSubHubWeb.ConnCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with connections 23 | import Plug.Conn 24 | import Phoenix.ConnTest 25 | import WebSubHubWeb.ConnCase 26 | 27 | alias WebSubHubWeb.Router.Helpers, as: Routes 28 | 29 | # The default endpoint for testing 30 | @endpoint WebSubHubWeb.Endpoint 31 | end 32 | end 33 | 34 | setup tags do 35 | pid = Ecto.Adapters.SQL.Sandbox.start_owner!(WebSubHub.Repo, shared: not tags[:async]) 36 | on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) 37 | {:ok, conn: Phoenix.ConnTest.build_conn()} 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule WebSubHub.DataCase do 2 | @moduledoc """ 3 | This module defines the setup for tests requiring 4 | access to the application's data layer. 5 | 6 | You may define functions here to be used as helpers in 7 | your tests. 8 | 9 | Finally, if the test case interacts with the database, 10 | we enable the SQL sandbox, so changes done to the database 11 | are reverted at the end of every test. If you are using 12 | PostgreSQL, you can even run database tests asynchronously 13 | by setting `use WebSubHub.DataCase, async: true`, although 14 | this option is not recommended for other databases. 15 | """ 16 | 17 | use ExUnit.CaseTemplate 18 | 19 | using do 20 | quote do 21 | alias WebSubHub.Repo 22 | 23 | import Ecto 24 | import Ecto.Changeset 25 | import Ecto.Query 26 | import WebSubHub.DataCase 27 | end 28 | end 29 | 30 | setup tags do 31 | pid = Ecto.Adapters.SQL.Sandbox.start_owner!(WebSubHub.Repo, shared: not tags[:async]) 32 | on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) 33 | :ok 34 | end 35 | 36 | @doc """ 37 | A helper that transforms changeset errors into a map of messages. 38 | 39 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 40 | assert "password is too short" in errors_on(changeset).password 41 | assert %{password: ["password is too short"]} = errors_on(changeset) 42 | 43 | """ 44 | def errors_on(changeset) do 45 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 46 | Regex.replace(~r"%{(\w+)}", message, fn _, key -> 47 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() 48 | end) 49 | end) 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/support/fixtures/subscriptions_fixtures.ex: -------------------------------------------------------------------------------- 1 | defmodule WebSubHub.SubscriptionsFixtures do 2 | @moduledoc """ 3 | This module defines test helpers for creating 4 | entities via the `WebSubHub.Subscriptions` context. 5 | """ 6 | 7 | @doc """ 8 | Generate a topic. 9 | """ 10 | def topic_fixture(attrs \\ %{}) do 11 | {:ok, topic} = 12 | attrs 13 | |> Enum.into(%{ 14 | url: "some url" 15 | }) 16 | |> WebSubHub.Subscriptions.create_topic() 17 | 18 | topic 19 | end 20 | 21 | @doc """ 22 | Generate a subscription. 23 | """ 24 | def subscription_fixture(attrs \\ %{}) do 25 | {:ok, subscription} = 26 | attrs 27 | |> Enum.into(%{ 28 | callback_url: "some callback_url", 29 | lease_seconds: 42, 30 | secret: "some secret", 31 | topic_id: 42 32 | }) 33 | |> WebSubHub.Subscriptions.create_subscription() 34 | 35 | subscription 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | Ecto.Adapters.SQL.Sandbox.mode(WebSubHub.Repo, :manual) 3 | 4 | {:ok, _} = Application.ensure_all_started(:fake_server) 5 | -------------------------------------------------------------------------------- /test/websubhub/hub_test.exs: -------------------------------------------------------------------------------- 1 | defmodule WebSubHub.HubTest do 2 | use WebSubHub.DataCase 3 | use Oban.Testing, repo: WebSubHub.Repo 4 | 5 | @html_body """ 6 | 7 | 8 | 9 | 10 | blah 11 | 12 | 13 |

I'm the content

14 | 15 | 16 | """ 17 | @text_body "Hello world" 18 | @json_body %{"hello" => "world"} 19 | 20 | @moduledoc """ 21 | Implements the tests described by https://websub.rocks/hub 22 | """ 23 | 24 | alias WebSubHub.Updates 25 | alias WebSubHub.Subscriptions 26 | 27 | describe "100 - Typical subscriber request" do 28 | @doc """ 29 | This subscriber will include only the parameters hub.mode, hub.topic and hub.callback. The hub should deliver notifications with no signature. 30 | """ 31 | 32 | setup [:setup_html_publisher, :setup_subscriber] 33 | 34 | test "100 - Typical subscriber request", %{ 35 | subscriber_pid: subscriber_pid, 36 | subscriber_url: subscriber_url, 37 | publisher_pid: publisher_pid, 38 | publisher_url: publisher_url 39 | } do 40 | topic_url = publisher_url 41 | callback_url = subscriber_url 42 | {:ok, subscription} = Subscriptions.subscribe(topic_url, callback_url) 43 | 44 | {:ok, update} = Updates.publish(topic_url) 45 | 46 | assert_enqueued( 47 | worker: WebSubHub.Jobs.DispatchPlainUpdate, 48 | args: %{ 49 | update_id: update.id, 50 | subscription_id: subscription.id, 51 | callback_url: callback_url, 52 | secret: nil 53 | } 54 | ) 55 | 56 | assert %{success: 1, failure: 0} = Oban.drain_queue(queue: :updates) 57 | 58 | assert hits(publisher_pid) == 1 59 | assert hits(subscriber_pid) == 2 60 | {:ok, [_challenge, publish]} = FakeServer.Instance.access_list(subscriber_pid) 61 | 62 | assert publish.body == @html_body 63 | end 64 | 65 | test "does not get publish if already unsubscribed", %{ 66 | subscriber_pid: subscriber_pid, 67 | subscriber_url: subscriber_url, 68 | publisher_pid: publisher_pid, 69 | publisher_url: publisher_url 70 | } do 71 | topic_url = publisher_url 72 | callback_url = subscriber_url 73 | {:ok, subscription} = Subscriptions.subscribe(topic_url, callback_url) 74 | 75 | {:ok, _} = Subscriptions.unsubscribe(topic_url, callback_url) 76 | 77 | # Quick sleep 78 | :timer.sleep(1000) 79 | 80 | {:ok, update} = Updates.publish(topic_url) 81 | 82 | refute_enqueued( 83 | worker: WebSubHub.Jobs.DispatchPlainUpdate, 84 | args: %{ 85 | update_id: update.id, 86 | subscription_id: subscription.id, 87 | callback_url: callback_url, 88 | secret: nil 89 | } 90 | ) 91 | 92 | assert hits(publisher_pid) == 1 93 | assert hits(subscriber_pid) == 2 94 | end 95 | end 96 | 97 | describe "101 - Subscriber includes a secret" do 98 | @doc """ 99 | This subscriber will include the parameters hub.mode, hub.topic, hub.callback and hub.secret. The hub should deliver notifications with a signature computed using this secret. 100 | """ 101 | 102 | setup [:setup_html_publisher, :setup_subscriber] 103 | 104 | test "101 - Subscriber includes a secret", %{ 105 | subscriber_pid: subscriber_pid, 106 | subscriber_url: subscriber_url, 107 | publisher_pid: publisher_pid, 108 | publisher_url: publisher_url 109 | } do 110 | topic_url = publisher_url 111 | callback_url = subscriber_url 112 | 113 | {:ok, subscription} = 114 | Subscriptions.subscribe(topic_url, callback_url, 864_000, "some_secret") 115 | 116 | {:ok, update} = Updates.publish(topic_url) 117 | 118 | assert_enqueued( 119 | worker: WebSubHub.Jobs.DispatchPlainUpdate, 120 | args: %{ 121 | update_id: update.id, 122 | subscription_id: subscription.id, 123 | callback_url: callback_url, 124 | secret: "some_secret" 125 | } 126 | ) 127 | 128 | assert %{success: 1, failure: 0} = Oban.drain_queue(queue: :updates) 129 | 130 | assert hits(publisher_pid) == 1 131 | assert hits(subscriber_pid) == 2 132 | 133 | {:ok, [_challenge, publish]} = FakeServer.Instance.access_list(subscriber_pid) 134 | 135 | assert publish.body == @html_body 136 | 137 | assert publish.headers["x-hub-signature"] == 138 | "sha256=9d63c6c06dca350aaa6955f9e4017b801fc56b4a904f2e4dab68652b6abfda4c" 139 | end 140 | end 141 | 142 | describe "102 - Subscriber sends additional parameters" do 143 | @doc """ 144 | This subscriber will include some additional parameters in the request, which must be ignored by the hub if the hub doesn't recognize them. 145 | """ 146 | test "102 - Subscriber sends additional parameters", %{} do 147 | end 148 | end 149 | 150 | @doc """ 151 | This subscriber tests whether the hub allows subscriptions to be re-subscribed before they expire. The hub must allow a subscription to be re-activated, and must update the previous subscription based on the topic+callback pair, rather than creating a new subscription. 152 | """ 153 | test "103 - Subscriber re-subscribes before the subscription expires", %{} do 154 | end 155 | 156 | @doc """ 157 | This test will first subscribe to a topic, and will then send an unsubscription request. You will be able to test that the unsubscription is confirmed by seeing that a notification is not received when a new post is published. 158 | """ 159 | test "104 - Unsubscribe request", %{} do 160 | end 161 | 162 | describe "105 - Plaintext content" do 163 | @doc """ 164 | This test will check whether your hub can handle delivering content that is not HTML or XML. The content at the topic URL of this test is plaintext. 165 | """ 166 | 167 | setup [:setup_text_publisher, :setup_subscriber] 168 | 169 | test "105 - Plaintext content", %{ 170 | subscriber_pid: subscriber_pid, 171 | subscriber_url: subscriber_url, 172 | publisher_pid: publisher_pid, 173 | publisher_url: publisher_url 174 | } do 175 | topic_url = publisher_url 176 | callback_url = subscriber_url 177 | {:ok, subscription} = Subscriptions.subscribe(topic_url, callback_url) 178 | 179 | {:ok, update} = Updates.publish(topic_url) 180 | 181 | assert_enqueued( 182 | worker: WebSubHub.Jobs.DispatchPlainUpdate, 183 | args: %{ 184 | update_id: update.id, 185 | subscription_id: subscription.id, 186 | callback_url: callback_url, 187 | secret: nil 188 | } 189 | ) 190 | 191 | assert %{success: 1, failure: 0} = Oban.drain_queue(queue: :updates) 192 | 193 | assert hits(publisher_pid) == 1 194 | assert hits(subscriber_pid) == 2 195 | {:ok, [_challenge, publish]} = FakeServer.Instance.access_list(subscriber_pid) 196 | 197 | assert publish.body == @text_body 198 | assert publish.headers["content-type"] == "text/plain" 199 | 200 | assert publish.headers["link"] == 201 | "<#{topic_url}>; rel=self, ; rel=hub" 202 | end 203 | end 204 | 205 | describe "106 - JSON content" do 206 | @doc """ 207 | This test will check whether your hub can handle delivering content that is not HTML or XML. The content at the topic URL of this test is JSON. 208 | """ 209 | 210 | setup [:setup_json_publisher, :setup_subscriber] 211 | 212 | test "106 - JSON content", %{ 213 | subscriber_pid: subscriber_pid, 214 | subscriber_url: subscriber_url, 215 | publisher_pid: publisher_pid, 216 | publisher_url: publisher_url 217 | } do 218 | topic_url = publisher_url 219 | callback_url = subscriber_url 220 | {:ok, subscription} = Subscriptions.subscribe(topic_url, callback_url) 221 | 222 | {:ok, update} = Updates.publish(topic_url) 223 | 224 | assert_enqueued( 225 | worker: WebSubHub.Jobs.DispatchPlainUpdate, 226 | args: %{ 227 | update_id: update.id, 228 | subscription_id: subscription.id, 229 | callback_url: callback_url, 230 | secret: nil 231 | } 232 | ) 233 | 234 | assert %{success: 1, failure: 0} = Oban.drain_queue(queue: :updates) 235 | 236 | assert hits(publisher_pid) == 1 237 | assert hits(subscriber_pid) == 2 238 | {:ok, [_challenge, publish]} = FakeServer.Instance.access_list(subscriber_pid) 239 | 240 | assert publish.body == @json_body 241 | assert publish.headers["content-type"] == "application/json" 242 | 243 | assert publish.headers["link"] == 244 | "<#{topic_url}>; rel=self, ; rel=hub" 245 | end 246 | end 247 | 248 | def setup_html_publisher(_) do 249 | {:ok, pid} = FakeServer.start(:publisher_server) 250 | port = FakeServer.port!(pid) 251 | 252 | on_exit(fn -> 253 | FakeServer.stop(pid) 254 | end) 255 | 256 | callback_path = "/posts" 257 | publisher_url = "http://localhost:#{port}" <> callback_path 258 | 259 | :ok = 260 | FakeServer.put_route(pid, callback_path, fn _ -> 261 | FakeServer.Response.ok( 262 | @html_body, 263 | %{ 264 | "Content-Type" => "text/html; charset=UTF-8" 265 | } 266 | ) 267 | end) 268 | 269 | [publisher_pid: pid, publisher_url: publisher_url] 270 | end 271 | 272 | def setup_text_publisher(_) do 273 | {:ok, pid} = FakeServer.start(:publisher_server) 274 | port = FakeServer.port!(pid) 275 | 276 | on_exit(fn -> 277 | FakeServer.stop(pid) 278 | end) 279 | 280 | callback_path = "/posts" 281 | publisher_url = "http://localhost:#{port}" <> callback_path 282 | 283 | :ok = 284 | FakeServer.put_route(pid, callback_path, fn _ -> 285 | FakeServer.Response.ok( 286 | @text_body, 287 | %{"Content-Type" => "text/plain"} 288 | ) 289 | end) 290 | 291 | [publisher_pid: pid, publisher_url: publisher_url] 292 | end 293 | 294 | def setup_json_publisher(_) do 295 | {:ok, pid} = FakeServer.start(:publisher_server) 296 | port = FakeServer.port!(pid) 297 | 298 | on_exit(fn -> 299 | FakeServer.stop(pid) 300 | end) 301 | 302 | callback_path = "/posts" 303 | publisher_url = "http://localhost:#{port}" <> callback_path 304 | 305 | :ok = 306 | FakeServer.put_route(pid, callback_path, fn _ -> 307 | FakeServer.Response.ok( 308 | @json_body, 309 | %{"Content-Type" => "application/json"} 310 | ) 311 | end) 312 | 313 | [publisher_pid: pid, publisher_url: publisher_url] 314 | end 315 | 316 | def setup_subscriber(_) do 317 | {:ok, pid} = FakeServer.start(:subscriber_server) 318 | port = FakeServer.port!(pid) 319 | 320 | on_exit(fn -> 321 | FakeServer.stop(pid) 322 | end) 323 | 324 | callback_path = "/callback" 325 | subscriber_url = "http://localhost:#{port}" <> callback_path 326 | 327 | :ok = 328 | FakeServer.put_route(pid, callback_path, fn req -> 329 | case req do 330 | %FakeServer.Request{method: "GET", query: %{"hub.challenge" => challenge}} -> 331 | FakeServer.Response.ok(challenge) 332 | 333 | %FakeServer.Request{method: "POST"} -> 334 | FakeServer.Response.ok() 335 | end 336 | end) 337 | 338 | [subscriber_pid: pid, subscriber_url: subscriber_url] 339 | end 340 | 341 | defp hits(subscriber_pid) do 342 | case FakeServer.Instance.access_list(subscriber_pid) do 343 | {:ok, access_list} -> length(access_list) 344 | {:error, _reason} -> 0 345 | end 346 | end 347 | end 348 | -------------------------------------------------------------------------------- /test/websubhub/subscriptions_test.exs: -------------------------------------------------------------------------------- 1 | defmodule WebSubHub.SubscriptionsTest do 2 | use WebSubHub.DataCase 3 | end 4 | -------------------------------------------------------------------------------- /test/websubhub/updates_test.exs: -------------------------------------------------------------------------------- 1 | defmodule WebSubHub.UpdatesTest do 2 | use WebSubHub.DataCase 3 | use Oban.Testing, repo: WebSubHub.Repo 4 | 5 | alias WebSubHub.Updates 6 | alias WebSubHub.Subscriptions 7 | 8 | setup do 9 | {:ok, pid} = FakeServer.start(:my_server) 10 | subscriber_port = FakeServer.port!(pid) 11 | 12 | on_exit(fn -> 13 | FakeServer.stop(pid) 14 | end) 15 | 16 | [subscriber_pid: pid, subscriber_url: "http://localhost:#{subscriber_port}"] 17 | end 18 | 19 | describe "updates" do 20 | # test "publishing update dispatches jobs", %{ 21 | # subscriber_pid: subscriber_pid, 22 | # subscriber_url: subscriber_url 23 | # } do 24 | # :ok = 25 | # FakeServer.put_route(subscriber_pid, "/cb", fn %{ 26 | # query: %{ 27 | # "hub.challenge" => challenge 28 | # } 29 | # } -> 30 | # FakeServer.Response.ok(challenge) 31 | # end) 32 | 33 | # topic_url = "https://topic/123" 34 | # callback_url = subscriber_url <> "/cb" 35 | # {:ok, _} = Subscriptions.subscribe(topic_url, callback_url) 36 | 37 | # {:ok, update} = Updates.publish(topic_url) 38 | 39 | # assert_enqueued( 40 | # worker: WebSubHub.Jobs.DispatchPlainUpdate, 41 | # args: %{update_id: update.id, callback_url: callback_url} 42 | # ) 43 | 44 | # assert hits(subscriber_pid) == 1 45 | # end 46 | end 47 | 48 | defp hits(subscriber_pid) do 49 | case FakeServer.Instance.access_list(subscriber_pid) do 50 | {:ok, access_list} -> length(access_list) 51 | {:error, _reason} -> 0 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/websubhub_web/controllers/hub_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule WebSubHubWeb.HubControllerTest do 2 | use WebSubHubWeb.ConnCase 3 | 4 | setup do 5 | {:ok, pid} = FakeServer.start(:my_server) 6 | subscriber_port = FakeServer.port!(pid) 7 | 8 | on_exit(fn -> 9 | FakeServer.stop(pid) 10 | end) 11 | 12 | [subscriber_pid: pid, subscriber_url: "http://localhost:#{subscriber_port}"] 13 | end 14 | 15 | test "subscribing to a specific topic", %{ 16 | conn: conn, 17 | subscriber_pid: subscriber_pid, 18 | subscriber_url: subscriber_url 19 | } do 20 | :ok = 21 | FakeServer.put_route(subscriber_pid, "/callback", fn %{ 22 | query: %{ 23 | "hub.challenge" => challenge 24 | } 25 | } -> 26 | FakeServer.Response.ok(challenge) 27 | end) 28 | 29 | params = %{ 30 | "hub.mode" => "subscribe", 31 | "hub.topic" => "http://localhost:1234/topic", 32 | "hub.callback" => "#{subscriber_url}/callback" 33 | } 34 | 35 | conn = form_post(conn, "/hub", params) 36 | 37 | assert response(conn, 202) =~ "" 38 | end 39 | 40 | test "subscribing with an invalid response", %{ 41 | conn: conn, 42 | subscriber_pid: subscriber_pid, 43 | subscriber_url: subscriber_url 44 | } do 45 | :ok = FakeServer.put_route(subscriber_pid, "/callback", FakeServer.Response.ok("whut?")) 46 | 47 | params = %{ 48 | "hub.mode" => "subscribe", 49 | "hub.topic" => "http://localhost:1234/topic", 50 | "hub.callback" => "#{subscriber_url}/callback" 51 | } 52 | 53 | conn = form_post(conn, "/hub", params) 54 | 55 | assert response(conn, 403) =~ "failed_challenge_body" 56 | end 57 | 58 | defp form_post(conn, path, params) do 59 | conn 60 | |> Plug.Conn.put_req_header("content-type", "application/x-www-form-urlencoded") 61 | |> post(path, params) 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/websubhub_web/controllers/page_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule WebSubHubWeb.PageControllerTest do 2 | use WebSubHubWeb.ConnCase 3 | 4 | test "GET /", %{conn: conn} do 5 | conn = get(conn, "/") 6 | assert html_response(conn, 200) =~ "Stable & Free WebSub Hub" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/websubhub_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule WebSubHubWeb.ErrorViewTest do 2 | use WebSubHubWeb.ConnCase, async: true 3 | 4 | # Bring render/3 and render_to_string/3 for testing custom views 5 | import Phoenix.View 6 | 7 | test "renders 404.html" do 8 | assert render_to_string(WebSubHubWeb.ErrorView, "404.html", []) == "Not Found" 9 | end 10 | 11 | test "renders 500.html" do 12 | assert render_to_string(WebSubHubWeb.ErrorView, "500.html", []) == "Internal Server Error" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/websubhub_web/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule WebSubHubWeb.LayoutViewTest do 2 | use WebSubHubWeb.ConnCase, async: true 3 | 4 | # When testing helpers, you may want to import Phoenix.HTML and 5 | # use functions such as safe_to_string() to convert the helper 6 | # result into an HTML string. 7 | # import Phoenix.HTML 8 | end 9 | -------------------------------------------------------------------------------- /test/websubhub_web/views/page_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule WebSubHubWeb.PageViewTest do 2 | use WebSubHubWeb.ConnCase, async: true 3 | end 4 | --------------------------------------------------------------------------------