~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2

grain-serve draws the OpenGraph cards links unfurl into

Merged chadtmiller.com asked to merge into feature/rust from grain.social/grain:og/cards

Only what this pull adds. It is built on grain runs on a Rust server modelled on knotless, without hatk.

crates/grain-serve/fonts/Inter-Regular.ttfnew+0−0
A binary file, not shown.
crates/grain-serve/fonts/NotoEmoji-Regular.ttfnew+0−0
A binary file, not shown.
crates/grain-serve/fonts/NotoSansJP-Regular.ttfnew+0−0
A binary file, not shown.
crates/grain-serve/fonts/OFL.txtnew+95−0
@@ -0,0 +1,95 @@
Inter: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
Syne: Copyright 2019 The Syne Project Authors (https://gitlab.com/bonjour-monde/fonderie/syne-typeface)
Noto Sans JP: Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'
Noto Emoji: Copyright 2013 Google LLC
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
crates/grain-serve/fonts/Syne-ExtraBold.ttfnew+0−0
A binary file, not shown.
crates/grain-serve/src/og/collage.rsnew+404−0
404 lines changed: not loaded until asked for.
crates/grain-serve/src/og/draw.rsnew+420−0
420 lines changed: not loaded until asked for.
crates/grain-serve/src/og/mod.rsnew+580−0
580 lines changed: not loaded until asked for.
crates/grain-serve/src/read/moderation.rs+11−0
@@ -50,3 +50,14 @@ pub fn not_orphaned(c: &str) -> String {
fn hidden_list() -> String {
HIDDEN_LABELS.map(|v| format!("'{v}'")).join(",")
}
/// SQL that holds where no label of grain's stands on the subject `uri`,
/// whatever it says: for what can neither blur nor warn, such as a card.
pub fn unlabeled(uri: &str) -> String {
format!(
"NOT EXISTS (SELECT 1 FROM label l WHERE l.uri = {uri} AND l.neg = 0
AND (l.exp IS NULL OR l.exp > strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
AND NOT EXISTS (SELECT 1 FROM label n WHERE n.uri = l.uri AND n.val = l.val
AND (n.cts > l.cts OR (n.cts = l.cts AND n.id > l.id))))"
)
}
crates/grain-serve/src/lib.rs+8−3
@@ -2,8 +2,8 @@
//! `social.grain.sync.indexRecords` ([`sync`]), the queries that read
//! the index alone ([`read`]), what an account has seen of its
//! notifications ([`notification`]) and the devices they are pushed to
//! ([`push`]), and the web's writes ([`write`]), and logs the web in
//! ([`login`]).
//! ([`push`]), and the web's writes ([`write`]), draws the cards links
//! unfurl into ([`og`]), and logs the web in ([`login`]).
pub mod backfill;
pub mod cdn;
@@ -11,6 +11,7 @@ pub mod index;
pub mod jetstream;
pub mod login;
pub mod notification;
pub mod og;
pub mod preferences;
pub mod push;
pub mod read;
@@ -164,6 +165,7 @@ impl Settings {
cdn,
bsky_appview: self.bsky_appview.clone(),
web,
cards: og::Cards::default(),
})
}
}
@@ -182,6 +184,8 @@ pub struct App {
pub bsky_appview: Option<String>,
/// The web's logins, for a server with a public URL and an index.
pub web: Option<Arc<login::Web>>,
/// The OpenGraph cards drawn lately.
pub cards: og::Cards,
}
impl App {
@@ -195,6 +199,7 @@ impl App {
cdn: cdn::Cdn::default(),
bsky_appview: None,
web: None,
cards: og::Cards::default(),
}
}
}
@@ -214,7 +219,7 @@ pub fn service_did(public_url: &str) -> anyhow::Result<String> {
/// The routes the server answers.
pub fn router(app: Arc<App>) -> Router {
read::routes(Router::new())
og::routes(read::routes(Router::new()))
.route("/xrpc/_health", get(health))
.route(
"/xrpc/social.grain.sync.indexRecords",
crates/grain-serve/tests/web/mod.rs+2−0
@@ -51,6 +51,7 @@ pub async fn site_with(index: Index, sign_up_pds: &str, until: Option<u64>) -> S
cdn: Default::default(),
bsky_appview: None,
web: Some(Arc::new(web)),
cards: Default::default(),
});
let served = common::served_on(listener, shared(&app));
let http = reqwest::Client::builder()
@@ -78,6 +79,7 @@ pub fn shared(app: &Arc<App>) -> App {
cdn: Default::default(),
bsky_appview: None,
web: app.web.clone(),
cards: Default::default(),
}
}
crates/grain-serve/tests/index_records.rs+1−0
@@ -59,6 +59,7 @@ async fn caller_with(extra: &[(&str, &str)]) -> Caller {
cdn: Default::default(),
bsky_appview: None,
web: None,
cards: Default::default(),
})
.await;
let account = net::account().await;
crates/grain-serve/tests/notifications.rs+1−0
@@ -48,6 +48,7 @@ async fn world() -> World {
cdn: Cdn::default(),
bsky_appview: None,
web: None,
cards: Default::default(),
})
.await;
let account = net::account().await;
crates/grain-serve/tests/og.rsnew+348−0
@@ -0,0 +1,348 @@
//! The OpenGraph cards, asked for as a link unfurler asks: drawn from the
//! index, with their images from a CDN of the test's own, which draws
//! every photo red and every avatar blue, and has no image for a blob
//! named `bafkreimissing`.
use axum::extract::Path;
use axum::http::header;
use grain::env::{Ctx, Env};
use grain_serve::App;
use grain_serve::cdn::Cdn;
use grain_serve::index::{Index, Label};
use serde_json::{Value, json};
use std::sync::Arc;
const NOW: &str = "2026-10-31T12:00:00.000Z";
const ALICE: &str = "did:plc:alice";
const BOB: &str = "did:plc:bob";
const RED: [u8; 3] = [255, 0, 0];
const BLUE: [u8; 3] = [0, 0, 255];
const WHITE: [u8; 3] = [255, 255, 255];
const BLACK: [u8; 3] = [0, 0, 0];
/// A PNG `w` by `h` of one color.
fn png(w: u32, h: u32, rgb: [u8; 3]) -> Vec<u8> {
let mut out = Vec::new();
let mut encoder = png::Encoder::new(&mut out, w, h);
encoder.set_color(png::ColorType::Rgb);
let mut writer = encoder.write_header().unwrap();
let data: Vec<u8> = (0..w * h).flat_map(|_| rgb).collect();
writer.write_image_data(&data).unwrap();
writer.finish().unwrap();
out
}
/// A CDN serving each image as imgproxy's presets name them.
async fn cdn() -> String {
let app = axum::Router::new().route(
"/{sig}/{preset}/plain/{did}/{cid}",
axum::routing::get(
|Path((_, preset, _, cid)): Path<(String, String, String, String)>| async move {
let rgb = if preset == "avatar" { BLUE } else { RED };
match cid.as_str() {
"bafkreimissing" => Err(axum::http::StatusCode::NOT_FOUND),
_ => Ok(([(header::CONTENT_TYPE, "image/png")], png(40, 30, rgb))),
}
},
),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, app).await });
url
}
struct World {
index: Arc<Index>,
base: String,
http: reqwest::Client,
}
async fn world() -> World {
let index = Arc::new(Index::memory().unwrap());
let url = cdn().await;
let app = App {
cdn: Cdn::new(Some(&url), Some("00"), Some("00")).unwrap(),
index: Some(index.clone()),
..App::bare(Ctx::new(Env::from_vars(|_| None)))
};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(grain_serve::serve(listener, Arc::new(app)));
for (did, handle) in [(ALICE, "alice.test"), (BOB, "bob.test")] {
index.track(did).unwrap();
index.set_handle(did, handle).unwrap();
}
World {
index,
base,
http: reqwest::Client::new(),
}
}
fn blob(cid: &str) -> Value {
json!({"$type": "blob", "ref": {"$link": cid}, "mimeType": "image/png", "size": 1000})
}
/// The colors of a card's pixels.
struct Drawn {
rgb: Vec<u8>,
}
impl Drawn {
fn at(&self, x: usize, y: usize) -> [u8; 3] {
let i = (y * 1200 + x) * 3;
[self.rgb[i], self.rgb[i + 1], self.rgb[i + 2]]
}
/// How many pixels are `rgb`.
fn count(&self, rgb: [u8; 3]) -> usize {
self.rgb
.as_chunks::<3>()
.0
.iter()
.filter(|p| **p == rgb)
.count()
}
}
impl World {
fn put(&self, did: &str, collection: &str, rkey: &str, mut record: Value) -> String {
let uri = format!("at://{did}/{collection}/{rkey}");
record["$type"] = collection.into();
let cid = grain::drisl::cid_text(&grain::drisl::cid(uri.as_bytes()));
self.index.put_at(&uri, &cid, &record, NOW).unwrap();
uri
}
fn profile(&self, did: &str, name: &str) {
let record =
json!({"displayName": name, "avatar": blob("bafkreiavatar"), "createdAt": NOW});
self.put(did, "social.grain.actor.profile", "self", record);
}
/// A gallery of `did`'s with a photo for each of `cids`, made at
/// `created`.
fn gallery(&self, did: &str, rkey: &str, cids: &[&str], extra: Value) -> String {
let mut record = json!({"title": "Fog", "createdAt": NOW});
for (k, v) in extra.as_object().unwrap() {
record[k] = v.clone();
}
let gallery = self.put(did, "social.grain.gallery", rkey, record);
for (i, cid) in cids.iter().enumerate() {
let rkey = format!("{rkey}p{i}");
let photo = json!({"photo": blob(cid), "aspectRatio": {"width": 3, "height": 2}, "createdAt": NOW});
let photo = self.put(did, "social.grain.photo", &rkey, photo);
let item = json!({"gallery": gallery, "item": photo, "position": i, "createdAt": NOW});
self.put(did, "social.grain.gallery.item", &rkey, item);
}
gallery
}
fn story(&self, did: &str, rkey: &str, extra: Value) -> String {
let mut record = json!({"media": blob("bafkreistory"), "aspectRatio": {"width": 4, "height": 3}, "createdAt": NOW});
for (k, v) in extra.as_object().unwrap() {
record[k] = v.clone();
}
self.put(did, "social.grain.story", rkey, record)
}
fn label(&self, uri: &str, val: &str) {
let label = Label {
src: "did:plc:labeler".into(),
uri: uri.into(),
val: val.into(),
neg: false,
cts: NOW.into(),
exp: None,
};
self.index.label(&label).unwrap();
}
/// The card at `path`, which is a PNG, cacheable for five minutes.
async fn card(&self, path: &str) -> Drawn {
let res = self
.http
.get(format!("{}{path}", self.base))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "{path}");
let headers = res.headers().clone();
assert_eq!(headers[header::CONTENT_TYPE], "image/png");
assert_eq!(headers[header::CACHE_CONTROL], "public, max-age=300");
assert_eq!(headers[header::ACCESS_CONTROL_ALLOW_ORIGIN], "*");
let bytes = res.bytes().await.unwrap();
let decoder = png::Decoder::new(std::io::Cursor::new(bytes));
let mut reader = decoder.read_info().unwrap();
let mut rgb = vec![0; reader.output_buffer_size().unwrap()];
let info = reader.next_frame(&mut rgb).unwrap();
assert_eq!((info.width, info.height), (1200, 630));
assert_eq!(info.color_type, png::ColorType::Rgb);
Drawn { rgb }
}
async fn status(&self, path: &str) -> u16 {
let res = self.http.get(format!("{}{path}", self.base)).send().await;
res.unwrap().status().as_u16()
}
}
/// Whether `card` says only what is missing: white, but for a line of
/// dark text in the middle.
fn missing(card: &Drawn) -> bool {
let dark = card
.rgb
.as_chunks::<3>()
.0
.iter()
.enumerate()
.filter(|(_, p)| p[0] < 128);
let rows: Vec<usize> = dark.map(|(i, _)| i / 1200).collect();
!rows.is_empty() && rows.iter().all(|y| (300..330).contains(y)) && card.count(RED) == 0
}
#[tokio::test]
async fn a_gallery_shows_its_photos_and_its_author() {
let w = world().await;
w.profile(ALICE, "Alice");
w.gallery(ALICE, "g1", &["bafkreione"], json!({}));
// A photo on its own fills the frame as far as its shape lets it.
let card = w.card("/og/profile/alice.test/gallery/g1").await;
assert_eq!(card.at(600, 265), RED);
assert_eq!(card.at(52, 574), BLUE, "the avatar");
assert_eq!(card.at(10, 10), WHITE);
// By DID too, and with some photos the CDN has no image for.
w.gallery(
ALICE,
"g2",
&["bafkreione", "bafkreimissing", "bafkreitwo"],
json!({}),
);
let card = w.card(&format!("/og/profile/{ALICE}/gallery/g2")).await;
assert!(card.count(RED) > 10_000);
// Asked again, the card is the one drawn, though the gallery changed.
w.label(&format!("at://{ALICE}/social.grain.gallery/g2"), "nudity");
let again = w.card(&format!("/og/profile/{ALICE}/gallery/g2")).await;
assert_eq!(again.rgb, card.rgb);
}
#[tokio::test]
async fn a_label_leaves_the_photos_out() {
let w = world().await;
w.profile(ALICE, "Alice");
let labeled = w.gallery(ALICE, "g1", &["bafkreione"], json!({}));
w.label(&labeled, "nudity");
let card = w.card("/og/profile/alice.test/gallery/g1").await;
assert_eq!(card.count(RED), 0);
assert_eq!(card.at(52, 574), BLUE, "the author is not labeled");
// Its author's label, as a self-label.
let self_labels = json!({"labels": {"$type": "com.atproto.label.defs#selfLabels", "values": [{"val": "nudity"}]}});
w.gallery(ALICE, "g2", &["bafkreione"], self_labels);
let card = w.card("/og/profile/alice.test/gallery/g2").await;
assert_eq!(card.count(RED), 0);
// A photo with a label of its own is left out of the rest.
let photo = format!("at://{ALICE}/social.grain.photo/g3p0");
w.gallery(ALICE, "g3", &["bafkreione"], json!({}));
w.label(&photo, "porn");
let card = w.card("/og/profile/alice.test/gallery/g3").await;
assert_eq!(card.count(RED), 0);
// A label on the account leaves out its photos and its avatar.
w.profile(BOB, "Bob");
w.gallery(BOB, "g1", &["bafkreione"], json!({}));
w.label(BOB, "spam");
let card = w.card("/og/profile/bob.test/gallery/g1").await;
assert_eq!(card.count(RED) + card.count(BLUE), 0);
}
#[tokio::test]
async fn what_is_not_shown_is_not_found() {
let w = world().await;
w.profile(ALICE, "Alice");
let hidden = w.gallery(ALICE, "g1", &["bafkreione"], json!({}));
w.label(&hidden, "!hide");
w.gallery(BOB, "g1", &["bafkreione"], json!({}));
w.story(BOB, "s1", json!({}));
w.index.take_down(BOB).unwrap();
for path in [
"/og/profile/alice.test/gallery/g1",
"/og/profile/alice.test/gallery/nope",
"/og/profile/nobody.test/gallery/g1",
"/og/profile/bob.test/gallery/g1",
"/og/profile/bob.test",
"/og/profile/nobody.test",
"/og/profile/bob.test/story/s1",
"/og/profile/alice.test/story/nope",
"/og/profile/nobody.test/story/s1",
] {
assert!(missing(&w.card(path).await), "{path}");
}
}
#[tokio::test]
async fn a_profile_shows_a_photo_of_each_of_its_latest_galleries() {
let w = world().await;
w.profile(ALICE, "Alice");
w.gallery(ALICE, "g1", &["bafkreione", "bafkreitwo"], json!({}));
let labeled = w.gallery(ALICE, "g2", &["bafkreione"], json!({}));
w.label(&labeled, "nudity");
let self_labels = json!({"labels": {"$type": "com.atproto.label.defs#selfLabels", "values": [{"val": "nudity"}]}});
w.gallery(ALICE, "g3", &["bafkreione"], self_labels);
// The one gallery left fills the frame with its first photo.
let card = w.card("/og/profile/alice.test").await;
assert_eq!(card.at(600, 265), RED);
assert_eq!(card.at(52, 574), BLUE);
// An account labeled shows neither.
w.label(
&format!("at://{ALICE}/social.grain.actor.profile/self"),
"spam",
);
let card = w.card(&format!("/og/profile/{ALICE}")).await;
assert_eq!(card.count(RED) + card.count(BLUE), 0);
// An account with no profile, and no handle that is one, is named by
// its DID.
w.index.track("did:plc:carol").unwrap();
let card = w.card("/og/profile/did:plc:carol").await;
assert!(!missing(&card));
assert_eq!(card.count(BLUE), 0);
}
#[tokio::test]
async fn a_story_shows_its_photo_on_black() {
let w = world().await;
w.profile(ALICE, "Alice");
w.story(ALICE, "s1", json!({}));
let card = w.card("/og/profile/alice.test/story/s1").await;
assert_eq!(card.at(600, 300), RED);
assert_eq!(card.at(10, 300), BLACK, "contained, not cut");
assert_eq!(card.at(46, 592), BLUE, "the avatar");
// With a label, the story's author along the foot is all it shows.
let self_labels = json!({"labels": {"$type": "com.atproto.label.defs#selfLabels", "values": [{"val": "nudity"}]}});
w.story(ALICE, "s2", self_labels);
let card = w.card("/og/profile/alice.test/story/s2").await;
assert_eq!(card.count(RED), 0);
assert_eq!(card.at(600, 300), BLACK);
// An author with no profile is named by their handle.
w.story(BOB, "s1", json!({}));
let card = w.card("/og/profile/bob.test/story/s1").await;
assert_eq!(card.count(BLUE), 0);
assert_eq!(card.at(600, 300), RED);
}
#[tokio::test]
async fn a_card_needs_an_index_that_reads() {
let ctx = Ctx::new(Env::from_vars(|_| None));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(grain_serve::serve(listener, Arc::new(App::bare(ctx))));
let res = reqwest::get(format!("{base}/og/profile/alice.test"))
.await
.unwrap();
assert_eq!(res.status(), 404);
let w = world().await;
w.index
.read(|db| db.execute_batch("DROP TABLE label"))
.unwrap();
assert_eq!(w.status("/og/profile/alice.test/gallery/g1").await, 500);
}
crates/grain-serve/tests/push.rs+1−0
@@ -40,6 +40,7 @@ async fn world() -> World {
cdn: Cdn::default(),
bsky_appview: None,
web: None,
cards: Default::default(),
})
.await;
let account = net::account().await;
crates/grain-serve/tests/read.rs+3−0
@@ -56,6 +56,7 @@ async fn world_of(cdn: Cdn, bsky_appview: Option<String>) -> World {
cdn,
bsky_appview,
web: None,
cards: Default::default(),
})
.await;
let account = net::account().await;
@@ -914,6 +915,7 @@ async fn the_viewer_is_who_their_token_says() {
cdn: Cdn::default(),
bsky_appview: None,
web: None,
cards: Default::default(),
})
.await;
let (status, body) = undid.get_with(&path, "Bearer x").await;
@@ -1042,6 +1044,7 @@ async fn the_feeds_are_named_by_uri() {
cdn: Cdn::default(),
bsky_appview: None,
web: None,
cards: Default::default(),
})
.await;
for path in [
crates/grain-serve/Cargo.toml+3−0
@@ -15,12 +15,15 @@ base64.workspace = true
bytes.workspace = true
futures.workspace = true
p256.workspace = true
png.workspace = true
reqwest.workspace = true
resvg.workspace = true
ring.workspace = true
rusqlite.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
skrifa.workspace = true
tempfile.workspace = true
tokio.workspace = true
tracing.workspace = true
Cargo.lock+405−1
406 lines changed: not loaded until asked for.
Cargo.toml+3−0