Compare commits

...

No commits in common. "9b43d5a783eaa9ce4bc2af3f5b4c3af14525c8ee" and "f3e279dcd3e7dadc4e9ba0840777f1d64d83dc2d" have entirely different histories.

58 changed files with 916 additions and 2740 deletions

1
.dev.vars Normal file
View file

@ -0,0 +1 @@
AUTHENTICATION_TOKEN=foobar

View file

@ -1,17 +0,0 @@
# flyctl launch added from .gitignore
# ---> Rust
# Generated by Cargo
# will have compiled files and executables
**/debug
**/target
# These are backup files generated by rustfmt
**/**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
**/*.pdb
**/.direnv
**/gs.*
**/result
fly.toml

View file

@ -1,31 +0,0 @@
name: Fly Deploy
on:
push:
branches:
- trunk
jobs:
deploy:
name: Deploy app
runs-on: debian-latest-arm
steps:
- uses: actions/checkout@v3.6.0
with:
ref: trunk
- uses: https://github.com/superfly/flyctl-actions/setup-flyctl@master
- run: flyctl deploy --remote-only
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
smoketest:
name: Smoketest
needs: deploy
runs-on: debian-latest-arm
steps:
- name: Checkout
uses: actions/checkout@v3.6.0
- name: Run k6 local test
uses: https://github.com/grafana/k6-action@v0.3.0
with:
filename: k6/smoketest.js
flags: -o experimental-prometheus-rw --tag testid=smoketest-${GITHUB_SHA}
env:
K6_PROMETHEUS_RW_SERVER_URL: ${{ secrets.PROMETHEUS_URL }}

21
.gitignore vendored
View file

@ -1,15 +1,6 @@
# ---> Rust /.direnv
# Generated by Cargo /result
# will have compiled files and executables /target
debug/ node_modules/
target/ wrangler/
.wrangler/
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
.direnv/
gs.*
result

1934
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,34 +1,28 @@
[package] [package]
name = "gabrielsimmerdotcom" name = "gabrielsimmercom"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
[profile.release] [lib]
strip = true # Automatically strip symbols from the binary. crate-type = ["cdylib", "rlib"]
[dependencies] [dependencies]
axum = { version = "0.6.18", features = ["json"] } worker = { version = "0.0.18", features = ["queue", "d1"] }
maud = { version = "*" }
serde_dhall = { version = "0.12.1", default-features = false }
serde = { version = "1.0.167", features = ["derive"] } serde = { version = "1.0.167", features = ["derive"] }
tokio = { version = "1.29.1", features = ["full"] } include_dir = "0.7.3"
maud = { version = "*", features = ["axum"] }
tower = { version = "0.4.13", features = ["util"] }
hyper = { version = "0.14", features = ["full"] }
tower-http = { version = "0.4.1", features = ["fs", "add-extension", "auth", "compression-full", "trace"] }
sha2 = "0.10.7"
hex = "0.4"
futures = "0.3.28"
comrak = "0.1"
orgize = { git = "https://git.gmem.ca/arch/orgize.git", branch = "org-images" }
clap = { version = "4.0", features = ["derive"] }
serde_dhall = "0.12.1"
frontmatter = "0.4.0" frontmatter = "0.4.0"
file-format = "0.18.0" comrak = { version = "0.21.0", default-features = false }
orgize = { git = "https://git.gmem.ca/arch/orgize.git", branch = "org-images", default-features = false }
rss = "2.0.6" rss = "2.0.6"
time = { version = "0.3.28", features = ["parsing", "formatting", "macros"] } time = { version = "0.3.31", features = ["parsing", "formatting", "macros", "wasm-bindgen"] }
async-trait = "0.1.73"
crossbeam = "0.8.2"
rand = "0.8.5"
prost = "0.12"
[build-dependencies] [profile.release]
prost-build = "0.12" lto = true
strip = true
codegen-units = 1
opt-level = 's'
[package.metadata.wasm-pack.profile.release]
wasm-opt = false

View file

@ -1,34 +0,0 @@
FROM rust:bookworm as builder
WORKDIR /usr/src/app
COPY . .
RUN apt-get update -y && apt-get install -y ca-certificates protobuf-compiler && \
apt-get clean autoclean && \
apt-get autoremove --yes && \
rm -rf /var/lib/{apt,dpkg,cache,log}/
# Will build and cache the binary and dependent crates in release mode
RUN --mount=type=cache,target=/usr/local/cargo,from=rust:latest,source=/usr/local/cargo \
--mount=type=cache,target=target \
cargo build --release && cp ./target/release/gabrielsimmerdotcom ./gabrielsimmerdotcom
# Runtime image
FROM debian:bookworm-slim
RUN apt-get update -y && apt-get install -y ca-certificates && \
apt-get clean autoclean && \
apt-get autoremove --yes && \
rm -rf /var/lib/{apt,dpkg,cache,log}/
WORKDIR /app
# Get compiled binaries from builder's cargo install directory
COPY --from=builder /usr/src/app/gabrielsimmerdotcom /app/gabrielsimmerdotcom
COPY --from=builder /usr/src/app/posts/ /app/posts/
COPY --from=builder /usr/src/app/assets/ /app/assets/
COPY --from=builder /usr/src/app/dhall/ /app/dhall
EXPOSE 8080
ENTRYPOINT ["/app/gabrielsimmerdotcom", "--bind", "0.0.0.0:8080"]

View file

@ -1,3 +0,0 @@
# gabrielsimmer.com
Overengineer the shit out of my personal site? Yes please.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 918 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 750 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 728 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 600 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

View file

@ -1,6 +0,0 @@
use std::io::Result;
fn main() -> Result<()> {
prost_build::compile_protos(&["protobuf/items.proto"], &["protobuf/"])?;
Ok(())
}

View file

@ -1,17 +0,0 @@
let Url : Type =
{ display_text : Text
, link : Text
}
let Project : Type =
{ name : Text
, description : Text
, link : Url
, languages : List Text
}
in
{ Project
, Url
}

View file

@ -1,26 +0,0 @@
let types = ./types.dhall
let Url = types.Url
let subtexts : List Text =
[ "Infrastructure with Love"
, "My fourth Kubernetes cluster"
, "Overengineer my personal site?"
, "Mythical full stack engineer"
]
let about : Text = ''
Working as a Senior Platform Engineer in food delivery and logistics. Currently living in the United Kingdom. Working on some cool things for Furality. Playing around with various things in my free time.
''
let links : List Url =
[ { display_text = "Fediverse"
, link = "https://floofy.tech/@arch"
}
]
in
{ about
, subtexts
, links
}

View file

@ -1,36 +1,18 @@
{ {
"nodes": { "nodes": {
"advisory-db": {
"flake": false,
"locked": {
"lastModified": 1696104803,
"narHash": "sha256-xdPl4PHzWaisHy4hQexpHJlx/3xAkCZrOFtEv3ygXco=",
"owner": "rustsec",
"repo": "advisory-db",
"rev": "46754ce9372c13a7e9a05cf7d812fadfdca9cf57",
"type": "github"
},
"original": {
"owner": "rustsec",
"repo": "advisory-db",
"type": "github"
}
},
"crane": { "crane": {
"inputs": { "inputs": {
"flake-compat": "flake-compat",
"flake-utils": "flake-utils",
"nixpkgs": [ "nixpkgs": [
"freight",
"nixpkgs" "nixpkgs"
], ]
"rust-overlay": "rust-overlay"
}, },
"locked": { "locked": {
"lastModified": 1696266955, "lastModified": 1698166613,
"narHash": "sha256-GhaBeBWwejBTzBQl803x7iUXQ6GGUZgBxz+qyk1E3v4=", "narHash": "sha256-y4rdN4flxRiROqNi1waMYIZj/Fs7L2OrszFk/1ry9vU=",
"owner": "ipetkov", "owner": "ipetkov",
"repo": "crane", "repo": "crane",
"rev": "581245bf1233d6f621ce3b6cb99224a948c3a37f", "rev": "b7db46f0f1751f7b1d1911f6be7daf568ad5bc65",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -41,39 +23,20 @@
}, },
"fenix": { "fenix": {
"inputs": { "inputs": {
"nixpkgs": [ "nixpkgs": "nixpkgs",
"nixpkgs" "rust-analyzer-src": "rust-analyzer-src"
],
"rust-analyzer-src": []
}, },
"locked": { "locked": {
"lastModified": 1696314121, "lastModified": 1698387704,
"narHash": "sha256-Dd0xm92D6cQ0c46uTMzBy7Zq9tyHScArpJtkzT87L4E=", "narHash": "sha256-Ei9J3yyiaEXKIJPmTqb2f3DPsg/XNfQLYvMxRwYzsH8=",
"owner": "nix-community", "owner": "nix-community",
"repo": "fenix", "repo": "fenix",
"rev": "c543df32930d075f807fc0b00c3101bc6a3b163d", "rev": "30701a50e292780bb1a8283f310bd456dd2d0ce5",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "nix-community", "id": "fenix",
"repo": "fenix", "type": "indirect"
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1673956053,
"narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
} }
}, },
"flake-utils": { "flake-utils": {
@ -81,11 +44,11 @@
"systems": "systems" "systems": "systems"
}, },
"locked": { "locked": {
"lastModified": 1694529238, "lastModified": 1705309234,
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", "narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=",
"owner": "numtide", "owner": "numtide",
"repo": "flake-utils", "repo": "flake-utils",
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384", "rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -94,71 +57,85 @@
"type": "github" "type": "github"
} }
}, },
"flake-utils_2": { "freight": {
"inputs": { "inputs": {
"systems": "systems_2" "crane": "crane",
"fenix": "fenix",
"flake-utils": [
"flake-utils"
],
"nixpkgs": [
"nixpkgs"
],
"workers-rs": "workers-rs"
}, },
"locked": { "locked": {
"lastModified": 1694529238, "lastModified": 1698438955,
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", "narHash": "sha256-6AUmcWUGblB4heOx1Rq541mj3oPICq68QVq8ixSKKKo=",
"owner": "numtide", "owner": "ivan770",
"repo": "flake-utils", "repo": "freight",
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384", "rev": "059a4656cb0c45972e4e8184c1f035877a5053ab",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "numtide", "owner": "ivan770",
"repo": "flake-utils", "repo": "freight",
"type": "github" "type": "github"
} }
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1696261572, "lastModified": 1698134075,
"narHash": "sha256-s8TtSYJ1LBpuITXjbPLUPyxzAKw35LhETcajJjCS5f0=", "narHash": "sha256-foCD+nuKzfh49bIoiCBur4+Fx1nozo+4C/6k8BYk4sg=",
"owner": "NixOS", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "0c7ffbc66e6d78c50c38e717ec91a2a14e0622fb", "rev": "8efd5d1e283604f75a808a20e6cde0ef313d07d4",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "NixOS", "owner": "nixos",
"ref": "nixpkgs-unstable", "ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1706191920,
"narHash": "sha256-eLihrZAPZX0R6RyM5fYAWeKVNuQPYjAkCUBr+JNvtdE=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "ae5c332cbb5827f6b1f02572496b141021de335f",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }
}, },
"root": { "root": {
"inputs": { "inputs": {
"advisory-db": "advisory-db", "flake-utils": "flake-utils",
"crane": "crane", "freight": "freight",
"fenix": "fenix", "nixpkgs": "nixpkgs_2"
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs"
} }
}, },
"rust-overlay": { "rust-analyzer-src": {
"inputs": { "flake": false,
"flake-utils": [
"crane",
"flake-utils"
],
"nixpkgs": [
"crane",
"nixpkgs"
]
},
"locked": { "locked": {
"lastModified": 1695003086, "lastModified": 1698337999,
"narHash": "sha256-d1/ZKuBRpxifmUf7FaedCqhy0lyVbqj44Oc2s+P5bdA=", "narHash": "sha256-UHk2hKVUN+t2v5x3u6un7FGA9Xlzs6gArs7Hi/FJXJs=",
"owner": "oxalica", "owner": "rust-lang",
"repo": "rust-overlay", "repo": "rust-analyzer",
"rev": "b87a14abea512d956f0b89d0d8a1e9b41f3e20ff", "rev": "46c395d57090f2ec5784d7fcad57a130911e44f7",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "oxalica", "owner": "rust-lang",
"repo": "rust-overlay", "ref": "nightly",
"repo": "rust-analyzer",
"type": "github" "type": "github"
} }
}, },
@ -177,18 +154,19 @@
"type": "github" "type": "github"
} }
}, },
"systems_2": { "workers-rs": {
"flake": false,
"locked": { "locked": {
"lastModified": 1681028828, "lastModified": 1696192204,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", "narHash": "sha256-+J+H7lQMvVzVFRGlw2I4PWCGUk4NYAvKuPumMyrwI1A=",
"owner": "nix-systems", "owner": "cloudflare",
"repo": "default", "repo": "workers-rs",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", "rev": "1d307145d9114d3241932ed2965fb1c65e6a67a9",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "nix-systems", "owner": "cloudflare",
"repo": "default", "repo": "workers-rs",
"type": "github" "type": "github"
} }
} }

155
flake.nix
View file

@ -1,153 +1,46 @@
{ {
description = "Build a cargo project";
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
crane = {
url = "github:ipetkov/crane";
inputs.nixpkgs.follows = "nixpkgs";
};
fenix = {
url = "github:nix-community/fenix";
inputs.nixpkgs.follows = "nixpkgs";
inputs.rust-analyzer-src.follows = "";
};
flake-utils.url = "github:numtide/flake-utils"; flake-utils.url = "github:numtide/flake-utils";
freight = {
advisory-db = { url = "github:ivan770/freight";
url = "github:rustsec/advisory-db"; inputs.nixpkgs.follows = "nixpkgs";
flake = false; inputs.flake-utils.follows = "flake-utils";
}; };
}; };
outputs = { self, nixpkgs, crane, fenix, flake-utils, advisory-db, ... }: outputs = {
flake-utils.lib.eachDefaultSystem (system: nixpkgs,
flake-utils,
freight,
...
}:
flake-utils.lib.eachDefaultSystem (
system:
let let
pkgs = import nixpkgs { pkgs = import nixpkgs {
inherit system; inherit system;
}; };
inherit (pkgs) lib; inherit (pkgs) lib; in
craneLib = crane.lib.${system};
src = lib.cleanSourceWith {
src = craneLib.path ./.; # The original, unfiltered source
filter = path: type:
let
protoFilter = path: _type: builtins.match ".*proto$" path != null;
protoOrCargo = path: type:
(protoFilter path type) || (craneLib.filterCargoSources path type);
in
protoOrCargo path type;
};
# Common arguments can be set here to avoid repeating them later
commonArgs = {
inherit src;
buildInputs = [
pkgs.sqlite
pkgs.pkg-config
pkgs.openssl
pkgs.protobuf
] ++ lib.optionals pkgs.stdenv.isDarwin [
# Additional darwin specific inputs can be set here
pkgs.libiconv
];
};
craneLibLLvmTools = craneLib.overrideToolchain
(fenix.packages.${system}.complete.withComponents [
"cargo"
"llvm-tools"
"rustc"
]);
# Build *just* the cargo dependencies, so we can reuse
# all of that work (e.g. via cachix) when running in CI
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
# Build the actual crate itself, reusing the dependency
# artifacts from above.
my-crate = craneLib.buildPackage (commonArgs // {
inherit cargoArtifacts;
});
in
{ {
checks = { packages.default = freight.lib.${system}.mkWorker {
# Build the crate as part of `nix flake check` for convenience pname = "gabrielsimmercom-workers";
inherit my-crate; version = "0.1.0";
# Run clippy (and deny all warnings) on the crate source, src = ./.;
# again, resuing the dependency artifacts from above.
#
# Note that this is done as a separate derivation so that
# we can block the CI if there are issues here, but not
# prevent downstream consumers from building our crate by itself.
my-crate-clippy = craneLib.cargoClippy (commonArgs // {
inherit cargoArtifacts;
cargoClippyExtraArgs = "--all-targets -- --deny warnings";
});
my-crate-doc = craneLib.cargoDoc (commonArgs // {
inherit cargoArtifacts;
});
# Check formatting
my-crate-fmt = craneLib.cargoFmt {
inherit src;
}; };
# Audit dependencies
my-crate-audit = craneLib.cargoAudit {
inherit src advisory-db;
};
# Run tests with cargo-nextest
# Consider setting `doCheck = false` on `my-crate` if you do not want
# the tests to run twice
my-crate-nextest = craneLib.cargoNextest (commonArgs // {
inherit cargoArtifacts;
partitions = 1;
partitionType = "count";
});
} // lib.optionalAttrs (system == "x86_64-linux") {
# NB: cargo-tarpaulin only supports x86_64 systems
# Check code coverage (note: this will not upload coverage anywhere)
my-crate-coverage = craneLib.cargoTarpaulin (commonArgs // {
inherit cargoArtifacts;
});
};
packages = {
default = my-crate;
my-crate-llvm-coverage = craneLibLLvmTools.cargoLlvmCov (commonArgs // {
inherit cargoArtifacts;
});
};
apps.default = flake-utils.lib.mkApp {
drv = my-crate;
};
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
inputsFrom = builtins.attrValues self.checks.${system};
# Additional dev-shell environment variables can be set directly
# MY_CUSTOM_DEVELOPMENT_VAR = "something else";
# Extra inputs can be added here
nativeBuildInputs = with pkgs; [ nativeBuildInputs = with pkgs; [
cargo cargo
rustc rustc
rust-analyzer rust-analyzer
sqlite nodePackages_latest.wrangler
sqlx-cli openssl
flyctl pkg-config
cargo-flamegraph cargo-bloat
]; ];
}; };
}); }
);
} }

View file

@ -1,14 +0,0 @@
# fly.toml app configuration file generated for gabrielsimmerdotcom on 2023-07-19T21:27:38+01:00
#
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
#
app = "gabrielsimmerdotcom"
primary_region = "lhr"
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = false
auto_start_machines = true
processes = ["app"]

View file

@ -1,12 +0,0 @@
import http from 'k6/http';
import { check, sleep} from 'k6';
export const options = {
vus: 3, // Key for Smoke test. Keep it at 2, 3, max 5 VUs
duration: '1m', // This can be shorter or just a few iterations
};
export default () => {
const urlRes = http.get('https://gabrielsimmer.com');
sleep(1);
};

View file

@ -1,9 +0,0 @@
-- Add migration script here
create table cached (
route text not null unique,
cached int not null,
content_type text not null,
content text not null
);
create unique index path_unique_idx on cached (lower(route));

View file

@ -0,0 +1,16 @@
#+title: Migrating Mastodon S3 Providers
#+date: 2024-03-08
As part of some recent work Kakious and I have been doing on the Mastodon instance we run, Floofy.tech, we were looking to cut costs while also improving performance. Part of that was migrating to new hardware using Proxmox rather than ESXi, and that migration was fairly unremarkable - we're still running everything in Kubernetes across multiple VMs, so there isn't really much to note (except we also moved to using Talos over k3s - I will have a longer post about that at some point). Another part of this was moving storage providers for the instance. We were using DigitalOcean Spaces, which served us well, but the pricing left quite a bit on the table. For $5/month, you get 250GiB of storage and 1TB of egress, with $0.02/GiB stored and $0.01GiB for egress after that. Our instance fell comfortably in this bucket, but /very/ comfortably, to the point we would certainly save money going elsewhere. Being employed at Cloudflare, and already having a decent setup for Floofy there, we turned to the R2 offering. With no egress costs and less than 100GiB stored (on average - depends how busy the fediverse is!), we shouldn't be paying anywhere near $5/month, and we're only paying for storage since egress is free and heavily cached.
So! With that decided, it was time to figure out our migration path. The plan was simple - using rclone, setup two remotes on a temporary virtual machine (or LXC container, in this case), and run a bulk transfer overnight. Once complete, we run one more sync then quickly swap the configuration on the Mastodon instance. The window of time between the last sync and Mastodon instances picking up the new configuration should be small enough that we don't miss any new media. Finally, we swap over the DNS to point to our R2 bucket, which should update quickly as the DNS was already proxied through Cloudflare.
Setup of the container was straightforward - we grabbed a Debian 12 image, installed rclone, and then setup two remotes. One pointed to our existing DigitalOcean Spaces bucket (=digitalocean-space=) and the other our new Cloudflare R2 bucket (=cloudflare-r2=). A quick =rclone lsd= on both remotes to confirm connection later, and then a dry run sync or two to verify, we were ready to go. I loaded up tmux, hit go, and waited.
It was going smoothly, until the container decided to drop its internet connection. I'm still not sure what caused this but after running =dhclient= it was fine again. The sync went off without a hitch otherwise.
When I woke up, it was time to run another sync then make the configuration change. I'd already changed the values in our Kubernetes state repository, so it was just a case of pushing it to GitHub and letting ArgoCD sync everything up. First, I reran the =rclone sync= to ensure that anything new was synced over, then quickly pushed the configuration up. It took about a minute to cycle the pods to the new configuration, at which point I removed the DNS record pointing to DigitalOcean and swapped it over to the Cloudflare R2 bucket. Done!
I genuinely expected this to be more difficult, but it really was that easy. This process would work for any rclone-compatible storage provider, of which there are many, so I'd feel pretty comfortable recommending this to others. Depending how busy your instance is, it may be worth doing a final =rclone copy= (which copies new files but doesn't delete from the target) to catch and stragglers after the configuration change, and depending on your DNS setup you may need to modify the time-to-live values ahead of the migration, but we didn't really hit those caveats.
Hopefully this is helpful to others - if you have any questions, feel free to poke me on the federated universe [[https://floofy.tech/@arch][@arch@floofy.tech]].

View file

@ -0,0 +1,31 @@
#+title: Porting to Workers
#+date: 2024-01-28
This website is now using Cloudflare Workers!
I went a little bit crazy over the weekend and decided to finally make the move from the three-region Fly.io deployment to Workers that run at the edge. This does mean I'm abandoning the gossip protocol based content cache dissemination implementation I was working on for the Fly.io deployment, but I still think the idea is valuable, and I'll try to find something else to add it to.
Choosing how to build this site on Workers was a bit tricky. My goal continues to be to overengineer what it ostensibly a static site for fun and negative profit. Thus, I completely ignored Cloudflare Pages. I wanted to keep a tiered caching strategy that I tried in the initial version of the Fly.io version, and it would be easier given I could leverage Cloudflare's caching systems. Ideally I want to regenerate pages from source when things change, but I also have to pay attention to how long it takes to execute a function when called. With Fly.io, I wanted fast page generation, but I didn't really care hold long it took since the CPU time wasn't limited in any way. Another limitation is that Workers don't really have a filesystem, so I'd have to leverage the various offerings available to store files. With Fly.io, I built the Docker image with the posts bundled in, which was great for speed but felt a little less than ideal since there was no clear separation between publishing a post and deploying changes to the backend.
So first, post storage! With Worker size limits, I decided to go with storing posts in their raw (=.org= or =.md=) format in an R2 bucket. While R2 is good for scaling storage, it does break my "live on the edge" mindset. So I needed a way to distribute the content to the edge, avoiding the Workers needing to reach into a region further away.
After some consideration, I scrapped the idea of generating and storing the result for other Workers on the fly and looked at the Queue option instead. The plan was to pre-render the content and store it somewhere (more on that later) so I can very quickly render content in the background when something is published. When a file is pushed to R2, I can fire of a webhook that queues up the new or changed files for rendering and storing on the edge. It does seem to introduce a little more latency when it comes to publishing content, but in reality it's faster because it doesn't require me to rebuild, push, and restart a container image.
Where to store the rendered content stuck with me for a bit. Initially I wanted to go with KV, since it seemed it would be faster, but I found after some experimentation it was substantially slower since there's no way to easily sort the keys based on content without reading /everything/ into memory and then sorting during Worker execution. Thankfully, I could reach for a real database, and created a D1 instance to hold a single table with the posts. It being SQLite based, I can just use SQL for the queries and take advantage of much more optimised codepaths for sorting or fetching the data I actually need. While D1 doesn't currently replicate, it will be a huge speed boost when it is!
/Note: this section originally said that D1 replicates. I was then told and disovered this is not the case at the moment. Whoops./
The workflow thus far is
1. A post is pushed to R2
2. A webhook is sent to a Worker (not by R2)
3. The worker fetches the list of files from R2 and queues them for "indexing"
4. Workers are executed to consume the queue, rendering the files and storing them in D1
The final piece is telling the Worker it can cache all the responses in Cloudflare's cache, and we're all set! Each response is cached for 4 hours before a Worker has to be hit to fetch the content from D1 again.
Of course, it wasn't all smooth sailing, which was mildly expected since D1 and Queues are both still in beta, and I'm using Rust, which, while having first party support through the [[https://github.com/cloudflare/workers-rs][workers-rs]] library, has some rough edges on Workers. The biggest one for my implementation is the inability to run the Worker locally when Queues are in use. Generally speaking my local development flow for this project needs improvement, and will be worked on, but the inability to run it locally with Queues (or even with =--remote= at the moment) makes iterating slow. I do believe this will improve over time, and when I hit blockers I plan to contribute back to the workers-rs library, but what I've needed to use works great.
[[https://cdn.gabrielsimmer.com/images/workers-publish-workflow.png]]
As per usual, I'm planning to continue iterating on the site, and hopefully make it faster and smaller over time. I do of course have the source available [[https://git.gmem.ca/arch/gabrielsimmer.com/src/branch/workers][in my Forgejo repo]], although there are still a few thing I need to do like implementing the actual CI/CD for publishing and webhooking. Feedback is welcome of course! Feel free to poke me [[https://floofy.tech/@arch][on the federated universe]].

View file

View file

@ -1,51 +0,0 @@
syntax = "proto3";
package gabrielsimmerdotcom.gossip;
message Ping {}
message PingRequest {
string peer = 1;
}
enum Status {
PEER_UNKNOWN = 0;
PEER_ALIVE = 1;
PEER_SUSPECT = 2;
PEER_CONFIRM = 3;
}
// { "127.0.0.1:1337", confirm }
message Membership {
string peer = 1;
Status status = 2;
int64 inc = 3;
}
message NewCache {
string key = 1;
string content = 2;
string content_type = 3;
}
message Cache {
string key = 1;
string content = 2;
string content_type = 3;
int64 timestamp = 4;
}
message Payload {
string peer = 1;
oneof msg {
Cache cache = 2;
Ping ping = 3;
Ack ack = 4;
Membership membership = 5;
NewCache new_cache = 6;
}
}
message Ack {
}

56
src/cache/mod.rs vendored
View file

@ -1,56 +0,0 @@
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
#[derive(Clone, Debug)]
pub struct CachedItem {
pub content_type: String,
pub content: String,
pub cached: i64,
}
/// Determine whether we should actually use the cached item or not.
fn should_use(_item: &CachedItem) -> bool {
// let current_time: i64 = SystemTime::now()
// .duration_since(UNIX_EPOCH)
// .expect("SystemTime before UNIX EPOCH!")
// .as_secs()
// .try_into()
// .unwrap();
// current_time <= (item.cached + (2*60)) && item.content != ""
true
}
static CACHE: OnceLock<Mutex<HashMap<String, CachedItem>>> = OnceLock::new();
pub async fn get(key: &String) -> Option<CachedItem> {
let data = CACHE
.get_or_init(|| Mutex::new(HashMap::new()))
.lock()
.unwrap();
match data.get(key) {
Some(c) => {
if should_use(&c) {
Some(c.clone())
} else {
//let _rm = rm(key.to_string()).await;
None
}
}
None => None,
}
}
// async fn rm(key: String) {
// let mut data = CACHE.get().unwrap().clone();
// data.remove(&key);
// let _ = CACHE.set(data);
// }
pub async fn set(key: String, item: CachedItem) {
let mut data = CACHE
.get_or_init(|| Mutex::new(HashMap::new()))
.lock()
.unwrap();
data.insert(key, item);
}

View file

@ -1,6 +1,14 @@
let types = ./types.dhall let Url : Type =
{ display_text : Text
, link : Text
}
let Project = types.Project let Project : Type =
{ name : Text
, description : Text
, link : Url
, languages : List Text
}
let projects : List Project = let projects : List Project =
[ { name = "GabrielSimmer.com" [ { name = "GabrielSimmer.com"

280
src/lib.rs Normal file
View file

@ -0,0 +1,280 @@
use posts::PostContent;
use worker::*;
use maud::{html, Markup, Render, DOCTYPE};
use serde::{Serialize, Deserialize};
use include_dir::{include_dir, Dir};
use time::{self, macros::format_description, format_description::well_known::Rfc2822};
mod posts;
#[derive(Deserialize, Debug)]
struct Project {
name: String,
description: String,
link: ProjectUrl,
languages: Vec<String>,
}
#[derive(Deserialize, Debug)]
struct ProjectUrl {
display_text: String,
link: String,
}
#[derive(Deserialize, Debug)]
struct ProjectConfig {
projects: Vec<Project>,
experiments: Vec<Project>,
}
#[derive(Serialize, Deserialize, Debug)]
struct IndexingItem {
name: String,
date: u64
}
static DHALL_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/dhall");
impl Render for Project {
fn render(&self) -> Markup {
html! {
div .project {
h4 { ( self.name ) " " }
@for language in &self.languages {
span .language .(language.to_lowercase()) { ( language ) }
}
p { ( self.description ) " " a href=(self.link.link) { (self.link.display_text) }}
}
}
}
}
#[event(fetch)]
async fn main(req: Request, env: Env, _ctx: Context) -> Result<Response> {
let router = Router::new();
router
.get_async("/", |req, _ctx| async move {
let cache = Cache::default();
let key = req.url()?.to_string();
if let Some(mut res) = cache.get(&key, false).await? {
let headers = res.headers_mut();
let _ = headers.set("cf-cache", "hit");
return Ok(res)
}
let mut res = Response::from_html(homepage().await.into_string())?;
let headers = res.headers_mut();
let _ = headers.set("Cache-Control", "public, max-age=14400");
cache.put(key, res.cloned()?).await?;
Ok(res)
})
.get_async("/assets/styles.css", |req, _ctx| async move {
let cache = Cache::default();
let key = req.url()?.to_string();
if let Some(mut res) = cache.get(&key, false).await? {
let headers = res.headers_mut();
let _ = headers.set("cf-cache", "hit");
return Ok(res)
}
let css = include_str!("styles.css");
let mut res = Response::ok(css)?;
let headers = res.headers_mut();
let _ = headers.set("Content-Type", "text/css");
let _ = headers.set("Cache-Control", "public, max-age=14400");
cache.put(key, res.cloned()?).await?;
Ok(res)
})
.get_async("/blog", |req, ctx| async move {
let cache = Cache::default();
let key = req.url()?.to_string();
if let Some(mut res) = cache.get(&key, false).await? {
let headers = res.headers_mut();
let _ = headers.set("cf-cache", "hit");
return Ok(res);
}
let index = ctx.env.d1("INDEX")?;
let statement = index.prepare("SELECT * FROM posts ORDER BY date DESC");
let posts: Vec<posts::PostMetadata> = statement.all().await?.results()?;
let html = html! {
(header("/blog"))
body {
main {
h1 { "Blog" }
a href = "/rss" { "RSS" }
ul {
@for post in posts {
(post);
}
}
}
}
};
let mut res = Response::from_html(html.into_string())?;
let headers = res.headers_mut();
let _ = headers.set("Cache-Control", "public, max-age=14400");
cache.put(key, res.cloned()?).await?;
return Ok(res);
})
.get_async("/blog/:slug", |req, ctx| async move {
let cache = Cache::default();
let key = req.url()?.to_string();
if let Some(mut res) = cache.get(&key, false).await? {
let headers = res.headers_mut();
let _ = headers.set("cf-cache", "hit");
return Ok(res)
}
let index = ctx.env.d1("INDEX")?;
let slug = ctx.param("slug").unwrap();
let statement = index.prepare("SELECT * FROM posts WHERE slug=?1");
let query = statement.bind(&[slug.into()])?;
let post = query.first::<PostContent>(None).await?;
let p = post.unwrap();
let html_maud = maud::PreEscaped(p.html);
let html = html! {
(header(p.title.as_str()))
body {
main {
h1 { (p.title) }
p { (p.date) }
(html_maud)
}
}
};
let mut res = Response::from_html(html.into_string())?;
let headers = res.headers_mut();
let _ = headers.set("Cache-Control", "public, max-age=14400");
cache.put(key, res.cloned()?).await?;
return Ok(res);
})
.get_async("/rss", |req, ctx| async move {
let cache = Cache::default();
let key = req.url()?.to_string();
if let Some(mut res) = cache.get(&key, false).await? {
let headers = res.headers_mut();
let _ = headers.set("cf-cache", "hit");
return Ok(res)
}
let index = ctx.env.d1("INDEX")?;
let statement = index.prepare("SELECT * FROM posts ORDER BY date DESC");
let posts: Vec<posts::PostMetadata> = statement.all().await?.results()?;
let rss_posts: Vec<rss::Item> = posts.into_iter().map(|p| {
let date = format!("{} 00:00:00 +00:00:00", p.date);
let format = format_description!("[year]-[month]-[day] [hour]:[minute]:[second] [offset_hour sign:mandatory]:[offset_minute]:[offset_second]");
let parsed_date = match time::OffsetDateTime::parse(&date, &format) {
Ok(r) => r,
Err(_e) => { time::OffsetDateTime::now_utc() },
};
let pub_date = match parsed_date.format(&Rfc2822) {
Ok(r) => r,
Err(_e) => { "".to_owned() },
};
rss::ItemBuilder::default()
.title(Some(p.title))
.link(Some(format!("https://gabrielsimmer.com/blog/{}", p.slug)))
.pub_date(Some(pub_date))
.build()
}).collect();
let channel = rss::ChannelBuilder::default()
.title("Gabriel Simmer's Blog".to_owned())
.link("https://gabrielsimmer.com/blog".to_owned())
.description("Gabriel Simmer's Blog Posts.".to_owned())
.items(rss_posts)
.build();
let mut res = Response::ok(channel.to_string())?;
let headers = res.headers_mut();
let _ = headers.set("Cache-Control", "public, max-age=14400");
let _ = headers.set("Content-Type", "application/rss+xml");
cache.put(key, res.cloned()?).await?;
Ok(res)
})
.get_async("/api/reindex", |req, ctx| async move {
let auth_header = req.headers().get("Authorization")?;
if auth_header.is_none() {
return Response::empty();
}
if auth_header.unwrap() != ctx.env.secret("AUTHENTICATION_TOKEN")?.to_string() {
return Response::empty();
}
let bucket = ctx.env.bucket("GABRIELSIMMERCOM_BUCKET")?;
let queue = ctx.env.queue("INDEXING")?;
let contents = bucket.list().prefix("posts/").execute().await?.objects();
for item in &contents {
let indexing_item = IndexingItem{
name: item.key(), date: item.uploaded().as_millis() };
queue.send(&indexing_item).await?;
}
let result = format!("queued {} items for reindexing", &contents.len());
Response::ok(result)
})
.run(req, env).await
}
async fn homepage() -> Markup {
let projects: ProjectConfig = serde_dhall::from_str(DHALL_DIR.get_file("projects.dhall").unwrap().contents_utf8().unwrap())
.parse()
.unwrap();
html! {
(header("/"))
body {
main {
h1 { "Gabriel Simmer" }
h2 { "Infrastructure and DevOps" }
p { a href = "/blog" { "Blog" } " " a href = "https://floofy.tech/@arch" rel = "me" { "Fediverse" } }
h3 { "Projects" }
@for project in projects.projects {
(project)
}
h3 { "Experiments" }
@for project in projects.experiments {
(project)
}
}
}
}
}
fn header(page_title: &str) -> Markup {
html! {
(DOCTYPE)
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1";
title { (page_title) }
link rel="stylesheet" href="/assets/styles.css";
link rel="alternate" type="application/rss+xml" title="Gabriel Simmer's Blog" href="/rss";
}
}
#[event(queue)]
async fn queue(message_batch: MessageBatch<IndexingItem>, env: Env, _ctx: Context) -> Result<()> {
let index = env.d1("INDEX")?;
let bucket = env.bucket("GABRIELSIMMERCOM_BUCKET")?;
let messages = message_batch.messages()?;
for message in messages {
let fname = message.body.name;
console_log!("got {} to render and index", &fname);
let file = bucket.get(&fname).execute().await?;
if file.is_none() {
console_warn!("did not find a file in bucket for {}", fname)
}
let rendered = posts::blog_post(fname.clone(), file.unwrap().body().unwrap().text().await?).unwrap();
let statement = index.prepare("INSERT OR IGNORE INTO posts (slug, title, html, date) VALUES (?1, ?2, ?3, ?4)");
let query = statement.bind(&[
rendered.slug.into(), rendered.title.into(), rendered.html.into(), rendered.date.into()
]).unwrap();
let _result = query.run().await?;
}
Ok(())
}

View file

@ -1,546 +0,0 @@
// Include the `items` module, which is generated from items.proto.
pub mod items {
include!(concat!(env!("OUT_DIR"), "/gabrielsimmerdotcom.gossip.rs"));
}
mod cache;
mod posts;
use axum::extract::Path;
use axum::response::IntoResponse;
use axum::{
body::Full,
extract::State,
http::StatusCode,
http::{Request, Response},
middleware::{self, Next},
routing::get,
Router,
};
use cache::CachedItem;
use clap::Parser;
use crossbeam::channel::{unbounded, Receiver, Sender};
use file_format::{FileFormat, Kind};
use hyper::body::Bytes;
use maud::{html, Markup, PreEscaped, Render, DOCTYPE};
use prost::Message;
use rand::seq::SliceRandom;
use rss::ChannelBuilder;
use serde::Deserialize;
use std::collections::HashMap;
use std::net::UdpSocket;
use std::time::{SystemTime, UNIX_EPOCH};
use std::{io, thread};
use time::{self, format_description, format_description::well_known::Rfc2822};
use tower_http::services::ServeDir;
#[derive(Debug)]
struct Peer {
counter: i64,
health: items::Status,
}
#[derive(Parser)]
struct Cli {
#[arg(short, long, default_value_t=("0.0.0.0:3000").to_string())]
bind: String,
#[arg(short, long)]
peers: Option<String>,
#[arg(long, default_value_t=("0.0.0.0:1337").to_string())]
gossip_bind: String,
}
#[derive(Clone, Debug)]
struct AppState {
to_gossip: Sender<items::Payload>,
}
#[derive(Deserialize)]
struct Project {
name: String,
description: String,
link: ProjectUrl,
languages: Vec<String>,
}
#[derive(Deserialize)]
struct ProjectUrl {
display_text: String,
link: String,
}
#[derive(Deserialize)]
struct ProjectConfig {
projects: Vec<Project>,
experiments: Vec<Project>,
}
impl Render for Project {
fn render(&self) -> Markup {
html! {
div .project {
h4 { ( self.name ) " " }
@for language in &self.languages {
span .language .(language.to_lowercase()) { ( language ) }
}
p { ( self.description ) " " a href=(self.link.link) { (self.link.display_text) }}
}
}
}
}
#[tokio::main]
async fn main() -> Result<(), ()> {
let args = Cli::parse();
// Create channels for sending messages and receiving results
let (s_gossip, r_gossip) = unbounded::<items::Payload>();
let (s_main, r_main) = unbounded::<items::Payload>();
let state = AppState {
to_gossip: s_main.clone(),
};
let app = Router::new()
.route("/", get(homepage))
.route("/rss", get(rss))
.route("/blog", get(list_blog_posts))
.route("/blog/:post", get(render_blog_post))
.route("/blog/:post/raw", get(raw_blog_post))
.nest_service("/assets", ServeDir::new("assets"))
.nest_service("/images", ServeDir::new("assets/images"))
.layer(middleware::from_fn_with_state(state.clone(), cached_page))
.with_state(state);
println!("Running webserver on {}", args.bind);
let webserver = axum::Server::bind(&args.bind.parse().unwrap()).serve(app.into_make_service());
if args.peers.is_some() {
tokio::spawn(webserver);
println!("starting gossip worker");
// Spawn a worker thread
let _gossip_server = thread::spawn(move || {
let binding = args.peers.unwrap();
let peer_list: Vec<&str> = binding.split(",").collect();
let _ = gossiper(
&args.gossip_bind,
peer_list,
s_main.clone(),
r_main,
s_gossip,
);
});
loop {
let message = r_gossip.recv().unwrap();
match &message.msg {
Some(items::payload::Msg::Cache(cache)) => {
cache::set(
cache.key.clone(),
CachedItem {
content_type: cache.content_type.clone(),
content: cache.content.clone(),
cached: cache.timestamp,
},
)
.await;
}
_ => {}
}
}
} else {
let _ = webserver.await;
}
Ok(())
}
fn gossiper(
bind: &String,
peer_list: Vec<&str>,
own_sender: Sender<items::Payload>,
rx: Receiver<items::Payload>,
tx: Sender<items::Payload>,
) -> io::Result<()> {
let mut peers: HashMap<String, Peer> = HashMap::new();
for peer in peer_list {
peers.insert(
peer.to_owned(),
Peer {
counter: 0,
health: items::Status::PeerAlive,
},
);
}
let mut buf = [0; 10240];
let socket = UdpSocket::bind(bind.as_str())?;
let r_socket = socket.try_clone().unwrap();
// When we come up, try to communicate we're alive to other peers.
let selected_peers: Vec<String> = match select_peers(&peers) {
Some(p) => {
println!("found peers");
p
}
None => {
println!("no peers, not gossiping");
vec![]
}
};
for peer in selected_peers {
let membership = items::Payload {
msg: Some(items::payload::Msg::Membership(items::Membership {
peer: bind.to_string(),
status: items::Status::PeerAlive as i32,
inc: 0,
})),
peer: bind.to_string(),
};
let response = Message::encode_to_vec(&membership);
let _result = match r_socket.send_to(&response, &peer) {
Ok(_) => "",
Err(_) => "",
};
let existing = peers.get(&peer).unwrap();
peers.insert(
peer,
Peer {
counter: existing.counter + 1,
health: items::Status::PeerAlive,
},
);
}
let bound = bind.clone();
thread::spawn(move || loop {
let (size, source) = socket.recv_from(&mut buf).expect("Failed to receive data");
let msg: Result<items::Payload, _> = Message::decode(&buf[..size]);
let _ = own_sender.send(msg.clone().unwrap()).unwrap();
if msg.unwrap().msg != Some(items::payload::Msg::Ack(items::Ack {})) {
let ack = items::Payload {
peer: bound.to_string(),
msg: Some(items::payload::Msg::Ack(items::Ack {})),
};
let res = Message::encode_to_vec(&ack);
socket
.send_to(&res, source)
.expect("Failed to send response");
}
});
// Handle messages that are passed to us from the main thread or the UDP socket.
loop {
let message = rx.recv().unwrap();
match &message.msg {
Some(items::payload::Msg::Cache(cache)) => {
println!("got new cache: {}", cache.content_type);
let _ = tx.send(message.clone()).unwrap();
}
Some(items::payload::Msg::Ack(_)) => {
println!("healthy ack");
let peer = message.peer;
let existing = peers.get(&peer).unwrap();
peers.insert(
peer,
Peer {
counter: existing.counter + 1,
health: items::Status::PeerAlive,
},
);
}
Some(items::payload::Msg::Ping(_)) => todo!(),
Some(items::payload::Msg::Membership(membership)) => {
println!("membership update");
let peer = message.peer;
let existing = peers.get(&peer).unwrap();
peers.insert(
peer,
Peer {
counter: existing.counter + 1,
health: membership.status(),
},
);
}
Some(items::payload::Msg::NewCache(new_cache)) => {
println!("instructed to send cache");
let current_time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("SystemTime before UNIX EPOCH!")
.as_secs()
.try_into()
.unwrap();
let cache = items::Payload {
peer: bind.to_owned(),
msg: Some(items::payload::Msg::Cache(items::Cache {
key: new_cache.key.clone(),
content: new_cache.content.clone(),
content_type: new_cache.content_type.clone(),
timestamp: current_time,
})),
};
let selected_peers: Vec<String> = match select_peers(&peers) {
Some(p) => {
println!("found peers");
p
}
None => {
println!("no peers, not gossiping");
vec![]
}
};
let payload = Message::encode_to_vec(&cache);
for peer in selected_peers {
dbg!(&peer);
let _result = match r_socket.send_to(&payload, &peer) {
Ok(_r) => {
// dbg!(r);
}
Err(e) => {
dbg!(e);
}
};
let p = peers.get_mut(&peer).unwrap();
p.counter = p.counter + 1;
}
}
None => {}
}
}
}
/// select a third of the peers we know about to gossip to.
fn select_peers(peers: &HashMap<String, Peer>) -> Option<Vec<String>> {
if peers.len() == 0 {
return None;
}
let healthy: Vec<String> = peers
.into_iter()
.filter(|(&ref k, &ref peer)| matches!(peer.health, items::Status::PeerAlive))
.map(|(&ref ip, &ref peer)| ip.to_owned())
.collect();
let mut len_rounded = healthy.len();
let rng = &mut rand::thread_rng();
if len_rounded > 3 {
let len: f64 = (healthy.len() / 3) as f64;
len_rounded = len.ceil() as usize;
}
// Select a random number of peers to gossip to.
Some(
healthy
.choose_multiple(rng, len_rounded)
.map(|f| f.to_owned())
.collect(),
)
}
async fn raw_blog_post(Path(post): Path<String>) -> Result<impl IntoResponse, StatusCode> {
let post = posts::blog_post(post);
if post.is_err() {
return Err(StatusCode::NOT_FOUND);
}
Ok(Response::builder()
.header("content-type", "text/plain")
.status(StatusCode::OK)
.body(Full::from(post.unwrap().content))
.unwrap())
}
async fn render_blog_post(Path(post): Path<String>) -> Result<impl IntoResponse, StatusCode> {
let post = posts::blog_post(post);
if post.is_err() {
return Err(StatusCode::NOT_FOUND);
}
let p = post.unwrap();
let html_maud = PreEscaped(p.html);
let html = html! {
(header(p.title.as_str()))
body {
main {
h1 { (p.title) }
p { (p.date) }
(html_maud)
}
}
};
Ok(Response::builder()
.header("content-type", "text/html")
.status(StatusCode::OK)
.body(Full::from(html.into_string()))
.unwrap())
}
async fn rss() -> Result<impl IntoResponse, StatusCode> {
let posts = posts::get_posts();
let rss_posts: Vec<rss::Item> = posts.into_iter().map(|p| {
let date = format!("{} 00:00:00 +00:00:00", p.date);
let format = format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second] [offset_hour sign:mandatory]:[offset_minute]:[offset_second]").unwrap();
let pub_date = match time::OffsetDateTime::parse(&date, &format).unwrap().format(&Rfc2822) {
Ok(r) => r,
Err(e) => { dbg!(e.to_string()); "".to_owned() },
};
rss::ItemBuilder::default()
.title(Some(p.name))
.link(Some(format!("https://gabrielsimmer.com/blog/{}", p.route)))
.pub_date(Some(pub_date))
.build()
}).collect();
let channel = ChannelBuilder::default()
.title("Gabriel Simmer's Blog".to_owned())
.link("https://gabrielsimmer.com/blog".to_owned())
.description("Gabriel Simmer's Blog Posts.".to_owned())
.items(rss_posts)
.build();
return Ok(Response::builder()
.header("content-type", "application/rss+xml")
.status(StatusCode::OK)
.body(Full::from(channel.to_string()))
.unwrap());
}
fn header(page_title: &str) -> Markup {
html! {
(DOCTYPE)
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1";
title { (page_title) }
link rel="stylesheet" href="/assets/styles.css";
link rel="alternate" type="application/rss+xml" title="Gabriel Simmer's Blog" href="/rss";
script async src="https://analytics.eu.umami.is/script.js" data-website-id="6cb8d5a5-703a-4b37-9e39-64c96a9a84c1" { };
}
}
async fn homepage() -> Markup {
let projects: ProjectConfig = serde_dhall::from_file("./dhall/projects.dhall")
.parse()
.unwrap();
html! {
(header("/"))
body {
main {
h1 { "Gabriel Simmer" }
h2 { "Infrastructure and DevOps" }
p { a href = "/blog" { "Blog" } " " a href = "https://floofy.tech/@arch" rel = "me" { "Fediverse" } }
h3 { "Projects" }
@for project in projects.projects {
(project)
}
h3 { "Experiments" }
@for project in projects.experiments {
(project)
}
}
}
}
}
async fn list_blog_posts() -> Markup {
let posts = posts::get_posts();
html! {
(header("/blog"))
body {
main {
h1 { "Blog" }
a href = "/rss" { "RSS" }
ul {
@for post in posts {
(post);
}
}
}
}
}
}
async fn cached_page<T>(
State(state): State<AppState>,
request: Request<T>,
next: Next<T>,
) -> Response<Full<Bytes>> {
let path = request.uri().path().to_string();
let item = cache::get(&path).await;
if item.is_none() {
let res = next.run(request).await;
let (res_parts, res_body) = res.into_parts();
let bytes = match hyper::body::to_bytes(res_body).await {
Ok(bytes) => bytes,
Err(_err) => {
return Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Full::from("error"))
.unwrap()
}
};
let res = bytes.to_vec();
let contenttype = match res_parts.headers.get("content-type") {
Some(c) => c.to_str().unwrap(),
None => "text/plain",
};
if !res_parts.status.is_success() {
return Response::builder()
.header("content-type", contenttype)
.status(res_parts.status)
.body(Full::from(bytes))
.unwrap();
}
// Make sure we only cache text.
let format = FileFormat::from_bytes(&res);
if format.kind() != Kind::Text && format.kind() != Kind::Application {
return Response::builder()
.header("content-type", contenttype)
.header("cache", "not")
.status(StatusCode::OK)
.body(Full::from(bytes))
.unwrap();
}
let current_time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("SystemTime before UNIX EPOCH!")
.as_secs()
.try_into()
.unwrap();
let content = String::from_utf8(res).unwrap();
cache::set(
path.clone(),
CachedItem {
content_type: contenttype.to_owned(),
content: content.clone(),
cached: current_time,
},
)
.await;
let gossip = items::Payload {
peer: "".to_owned(),
msg: Some(items::payload::Msg::NewCache(items::NewCache {
key: path,
content,
content_type: contenttype.to_owned(),
})),
};
match state.to_gossip.send(gossip) {
Ok(_) => {}
Err(_) => {}
};
return Response::builder()
.header("content-type", contenttype)
.header("cache", "miss")
.status(StatusCode::OK)
.body(Full::from(bytes))
.unwrap();
} else {
let i = item.unwrap();
return Response::builder()
.header("content-type", &i.content_type)
.header("cache", format!("hit"))
.status(StatusCode::OK)
.body(Full::from(i.content))
.unwrap();
}
}

View file

@ -1,105 +1,52 @@
use std::{
fs::{self, File},
io::Read,
};
use maud::{html, Markup, Render}; use maud::{html, Markup, Render};
use orgize::Org; use orgize::Org;
use serde::{Serialize, Deserialize};
use std::{path::Path, ffi::OsStr};
#[derive(Debug, Serialize, Deserialize)]
pub struct PostMetadata { pub struct PostMetadata {
pub name: String, pub slug: String,
pub route: String, pub title: String,
pub date: String, pub date: String,
} }
#[derive(Debug, Serialize, Deserialize)]
pub struct PostContent { pub struct PostContent {
pub slug: String,
pub title: String, pub title: String,
pub date: String, pub date: String,
pub content: String,
pub html: String, pub html: String,
} }
impl Render for PostMetadata { impl Render for PostMetadata {
fn render(&self) -> Markup { fn render(&self) -> Markup {
html! { html! {
li { (self.date) " - " a href=(format!("/blog/{}", self.route)) { (self.name) } } li { (self.date) " - " a href=(format!("/blog/{}", self.slug)) { (self.title) } }
} }
} }
} }
pub fn get_posts() -> Vec<PostMetadata> {
let mut posts: Vec<PostMetadata> = Vec::new();
for entry in fs::read_dir("./posts").unwrap() {
let entry = entry.unwrap();
let path = entry.path();
let filename = path.file_name().unwrap().to_str().unwrap();
let ext = path.extension().unwrap().to_str().unwrap();
// strip extension
let fname = filename.replace(&format!(".{}", ext), "");
if ext == "md" || ext == "org" {
// We'll have the date at the beginning of the file
let mut content = File::open(&path).unwrap();
let mut buffer = [0; 100];
content.read(&mut buffer).unwrap();
// Match date data of `date: YYYY-MM-DD` in the first 100 bytes
let metadata = String::from_utf8_lossy(&buffer);
let metadata_lines = metadata.split("\n").collect::<Vec<&str>>();
// dbg!(&metadata);
// Split by --- and get the second element
let date = metadata_lines
.iter()
.find(|&x| x.contains("date:"))
.unwrap_or(&"")
.split(":")
.collect::<Vec<&str>>()[1];
let title = metadata_lines
.iter()
.find(|&x| x.contains("title:"))
.unwrap_or(&"")
.split(":")
.collect::<Vec<&str>>()[1]
.trim();
let date = date.trim();
posts.push(PostMetadata {
name: title.to_owned(),
route: fname,
date: date.to_owned(),
});
}
}
posts.sort_by(|a, b| b.date.cmp(&a.date));
posts
}
// Render the actual blog post as HTML. // Render the actual blog post as HTML.
pub fn blog_post(post: String) -> Result<PostContent, bool> { pub fn blog_post(fname: String, post: String) -> Result<PostContent, bool> {
// Search through /posts directory and find the post with either .md or .org extension // Search through /posts directory and find the post with either .md or .org extension
// If the post is not found, return 404 // If the post is not found, return 404
for entry in fs::read_dir("./posts").unwrap() {
let entry = entry.unwrap();
let path = entry.path();
let filename = path.file_name().unwrap().to_str().unwrap();
let ext = path.extension().unwrap().to_str().unwrap();
// strip extension
let fname = filename.replace(&format!(".{}", ext), "");
if fname == post && (ext == "md" || ext == "org") {
let content = fs::read_to_string(&path).unwrap();
let ext = Path::new(&fname).extension().and_then(OsStr::to_str).unwrap();
let ext_name = format!(".{}", ext);
let slug = fname.replace("posts/", "").replace( &ext_name, "");
let mut html = "".to_owned(); let mut html = "".to_owned();
let mut date = "".to_owned(); let mut date = "".to_owned();
let mut title = "".to_owned(); let mut title = "".to_owned();
if ext == "md" { if ext == "md" {
let (parsed, content) = frontmatter::parse_and_find_content(&content).unwrap(); let (parsed, content) = frontmatter::parse_and_find_content(&post).unwrap();
let metadata = parsed.unwrap(); let metadata = parsed.unwrap();
date = metadata["date"].as_str().unwrap().to_owned(); date = metadata["date"].as_str().unwrap().to_owned();
title = metadata["title"].as_str().unwrap().to_owned(); title = metadata["title"].as_str().unwrap().to_owned();
html = comrak::markdown_to_html(&content, &comrak::ComrakOptions::default()); html = comrak::markdown_to_html(&content, &comrak::ComrakOptions::default());
} else if ext == "org" { } else if ext == "org" {
let mut writer = Vec::new(); let mut writer = Vec::new();
let parsed = Org::parse(&content);
let parsed = Org::parse(&post);
let keywords = parsed.keywords(); let keywords = parsed.keywords();
// Get date and title from keywords iterator // Get date and title from keywords iterator
@ -113,14 +60,10 @@ pub fn blog_post(post: String) -> Result<PostContent, bool> {
parsed.write_html(&mut writer).unwrap(); parsed.write_html(&mut writer).unwrap();
html = String::from_utf8(writer).unwrap(); html = String::from_utf8(writer).unwrap();
} }
return Ok(PostContent { Ok(PostContent {
slug,
title, title,
date, date,
content,
html, html,
}); })
}
}
return Err(false);
} }

2
src/schema.sql Normal file
View file

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS posts;
CREATE TABLE IF NOT EXISTS posts (slug TEXT PRIMARY KEY, title TEXT, html TEXT, date TEXT);

29
wrangler.toml Normal file
View file

@ -0,0 +1,29 @@
name = "gabrielsimmercom-workers"
main = "result/shim.mjs"
compatibility_date = "2023-10-22"
account_id = "7dc420732ea679a530aee304ea49a63c"
workers_dev = true
[limits]
cpu_ms = 100
[build]
command = "nix build"
[[r2_buckets]]
binding = 'GABRIELSIMMERCOM_BUCKET' # <~ valid JavaScript variable name
bucket_name = 'gabrielsimmer-com'
preview_bucket_name = 'gabrielsimmer-com-dev'
[[d1_databases]]
binding = "INDEX"
database_name = "gabrielsimmercom"
database_id = "53acce7f-f529-4392-aa41-14084c445af0"
[[queues.producers]]
binding = "INDEXING"
queue = "gabrielsimmercom-indexing"
[[queues.consumers]]
queue = "gabrielsimmercom-indexing"