0
2
grain-serve draws the OpenGraph cards links unfurl into
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.