Merge branch 'main' of github.com:cloudflare/wildebeest into main

pull/295/head
Celso Martinho 2023-02-15 19:53:02 +00:00
commit a043c059d0
78 zmienionych plików z 2010 dodań i 2076 usunięć

Wyświetl plik

@ -6,6 +6,7 @@ import type { Handle } from 'wildebeest/backend/src/utils/parse'
import { queryAcct } from 'wildebeest/backend/src/webfinger/index'
import { loadExternalMastodonAccount, loadLocalMastodonAccount } from 'wildebeest/backend/src/mastodon/account'
import { MastodonAccount } from '../types'
import { adjustLocalHostDomain } from '../utils/adjustLocalHostDomain'
export async function getAccount(domain: string, accountId: string, db: D1Database): Promise<MastodonAccount | null> {
const handle = parseHandle(accountId)
@ -44,16 +45,3 @@ async function getLocalAccount(domain: string, db: D1Database, handle: Handle):
return await loadLocalMastodonAccount(db, actor)
}
/**
* checks if a domain is a localhost one ('localhost' or '127.x.x.x') and
* in that case replaces it with '0.0.0.0' (which is what we use for our local data)
*
* Note: only needed for local development
*
* @param domain the potentially localhost domain
* @returns the adjusted domain if it was a localhost one, the original domain otherwise
*/
function adjustLocalHostDomain(domain: string) {
return domain.replace(/^localhost$|^127(\.(?:\d){1,3}){3}$/, '0.0.0.0')
}

Wyświetl plik

@ -34,6 +34,8 @@ export interface Actor extends APObject {
following: URL
followers: URL
alsoKnownAs?: string
[emailSymbol]: string
}
@ -200,6 +202,16 @@ export async function updateActorProperty(db: D1Database, actorId: URL, key: str
}
}
export async function setActorAlias(db: D1Database, actorId: URL, alias: URL) {
const { success, error } = await db
.prepare(`UPDATE actors SET properties=json_set(properties, '$.alsoKnownAs', json_array(?)) WHERE id=?`)
.bind(alias.toString(), actorId.toString())
.run()
if (!success) {
throw new Error('SQL error: ' + error)
}
}
export async function getActorById(db: D1Database, id: URL): Promise<Actor | null> {
const stmt = db.prepare('SELECT * FROM actors WHERE id=?').bind(id.toString())
const { results } = await stmt.all()

Wyświetl plik

@ -299,6 +299,7 @@ export async function deleteObject<T extends APObject>(db: D1Database, note: T)
db.prepare('DELETE FROM actor_reblogs WHERE object_id=?').bind(nodeId),
db.prepare('DELETE FROM actor_replies WHERE object_id=?1 OR in_reply_to_object_id=?1').bind(nodeId),
db.prepare('DELETE FROM idempotency_keys WHERE object_id=?').bind(nodeId),
db.prepare('DELETE FROM note_hashtags WHERE object_id=?').bind(nodeId),
db.prepare('DELETE FROM objects WHERE id=?').bind(nodeId),
]

Wyświetl plik

@ -1,6 +1,7 @@
import type { APObject } from 'wildebeest/backend/src/activitypub/objects'
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-link
export interface Link {
type: string
export interface Link extends APObject {
href: URL
name: string
}

Wyświetl plik

@ -2,14 +2,12 @@ import type { Link } from 'wildebeest/backend/src/activitypub/objects/link'
import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
import { urlToHandle } from 'wildebeest/backend/src/utils/handle'
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-mention
export interface Mention extends Link {}
export function newMention(actor: Actor): Mention {
return {
type: 'Mention',
id: actor.id,
url: actor.id,
href: actor.id,
name: urlToHandle(actor.id),
}

Wyświetl plik

@ -39,13 +39,21 @@ export function internalServerError(): Response {
}
export function statusNotFound(id: string): Response {
return generateErrorResponse('Resource not found', 404, `Status "${id}" not found`)
return resourceNotFound('status', id)
}
export function mediaNotFound(id: string): Response {
return generateErrorResponse('Resource not found', 404, `Media "${id}" not found`)
return resourceNotFound('media', id)
}
export function tagNotFound(tag: string): Response {
return resourceNotFound('tag', tag)
}
export function exceededLimit(detail: string): Response {
return generateErrorResponse('Limit exceeded', 400, detail)
}
export function resourceNotFound(name: string, id: string): Response {
return generateErrorResponse('Resource not found', 404, `${name} "${id}" not found`)
}

Wyświetl plik

@ -1,6 +1,6 @@
import { MastodonAccount } from 'wildebeest/backend/src/types/account'
import { unwrapPrivateKey } from 'wildebeest/backend/src/utils/key-ops'
import type { Actor } from '../activitypub/actors'
import { Actor } from '../activitypub/actors'
import { defaultImages } from 'wildebeest/config/accounts'
import * as apOutbox from 'wildebeest/backend/src/activitypub/actors/outbox'
import * as apFollow from 'wildebeest/backend/src/activitypub/actors/follow'

Wyświetl plik

@ -0,0 +1,50 @@
import type { Note } from 'wildebeest/backend/src/activitypub/objects/note'
import type { Tag } from 'wildebeest/backend/src/types/tag'
export type Hashtag = string
const HASHTAG_RE = /#([\S]+)/g
export function getHashtags(input: string): Array<Hashtag> {
const matches = input.matchAll(HASHTAG_RE)
if (matches === null) {
return []
}
return [...matches].map((match) => match[1])
}
export async function insertHashtags(db: D1Database, note: Note, values: Array<Hashtag>): Promise<void> {
const queries = []
const stmt = db.prepare(`
INSERT INTO note_hashtags (value, object_id)
VALUES (?, ?)
`)
for (let i = 0, len = values.length; i < len; i++) {
const value = values[i]
queries.push(stmt.bind(value, note.id.toString()))
}
await db.batch(queries)
}
export async function getTag(db: D1Database, domain: string, tag: string): Promise<Tag | null> {
const query = `
SELECT * FROM note_hashtags WHERE value=?
`
const { results, success, error } = await db.prepare(query).bind(tag).all<{ value: string }>()
if (!success) {
throw new Error('SQL error: ' + error)
}
if (!results || results.length === 0) {
return null
}
return {
name: results[0].value,
url: new URL(`/tags/${results[0].value}`, `https://${domain}`),
history: [],
}
}

Wyświetl plik

@ -257,14 +257,16 @@ export async function getNotifications(db: D1Database, actor: Actor, domain: str
url: new URL('/statuses/' + result.mastodon_id, 'https://' + domain),
created_at: new Date(result.cdate).toISOString(),
account,
// TODO: stub values
emojis: [],
media_attachments: [],
tags: [],
mentions: [],
account,
// TODO: stub values
replies_count: 0,
reblogs_count: 0,
favourites_count: 0,
visibility: 'public',
spoiler_text: '',
}

Wyświetl plik

@ -26,13 +26,28 @@ export interface CreateRequest {
admin_sign_up?: boolean
admin_report?: boolean
}
policy: string
policy?: string
}
}
export type Subscription = {
id: string
// While the spec says to use a string as id (https://docs.joinmastodon.org/entities/WebPushSubscription/#id), Mastodon's android app decided to violate that (https://github.com/mastodon/mastodon-android/blob/master/mastodon/src/main/java/org/joinmastodon/android/model/PushSubscription.java#LL11).
id: number
gateway: PushSubscription
alerts: {
mention: boolean
status: boolean
reblog: boolean
follow: boolean
follow_request: boolean
favourite: boolean
poll: boolean
update: boolean
admin_sign_up: boolean
admin_report: boolean
}
policy: string
}
export async function createSubscription(
@ -41,39 +56,33 @@ export async function createSubscription(
client: Client,
req: CreateRequest
): Promise<Subscription> {
const id = crypto.randomUUID()
const query = `
INSERT INTO subscriptions (id, actor_id, client_id, endpoint, key_p256dh, key_auth, alert_mention, alert_status, alert_reblog, alert_follow, alert_follow_request, alert_favourite, alert_poll, alert_update, alert_admin_sign_up, alert_admin_report, policy)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO subscriptions (actor_id, client_id, endpoint, key_p256dh, key_auth, alert_mention, alert_status, alert_reblog, alert_follow, alert_follow_request, alert_favourite, alert_poll, alert_update, alert_admin_sign_up, alert_admin_report, policy)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING *
`
const out = await db
const row = await db
.prepare(query)
.bind(
id,
actor.id.toString(),
client.id,
req.subscription.endpoint,
req.subscription.keys.p256dh,
req.subscription.keys.auth,
req.data.alerts.mention ? 1 : 0,
req.data.alerts.status ? 1 : 0,
req.data.alerts.reblog ? 1 : 0,
req.data.alerts.follow ? 1 : 0,
req.data.alerts.follow_request ? 1 : 0,
req.data.alerts.favourite ? 1 : 0,
req.data.alerts.poll ? 1 : 0,
req.data.alerts.update ? 1 : 0,
req.data.alerts.admin_sign_up ? 1 : 0,
req.data.alerts.admin_report ? 1 : 0,
req.data.policy
req.data.alerts.mention === false ? 0 : 1,
req.data.alerts.status === false ? 0 : 1,
req.data.alerts.reblog === false ? 0 : 1,
req.data.alerts.follow === false ? 0 : 1,
req.data.alerts.follow_request === false ? 0 : 1,
req.data.alerts.favourite === false ? 0 : 1,
req.data.alerts.poll === false ? 0 : 1,
req.data.alerts.update === false ? 0 : 1,
req.data.alerts.admin_sign_up === false ? 0 : 1,
req.data.alerts.admin_report === false ? 0 : 1,
req.data.policy ?? 'all'
)
.run()
if (!out.success) {
throw new Error('SQL error: ' + out.error)
}
return { id, gateway: req.subscription }
.first<any>()
return subscriptionFromRow(row)
}
export async function getSubscription(db: D1Database, actor: Actor, client: Client): Promise<Subscription | null> {
@ -121,6 +130,19 @@ function subscriptionFromRow(row: any): Subscription {
auth: row.key_auth,
},
},
alerts: {
mention: row.alert_mention === 1,
status: row.alert_status === 1,
reblog: row.alert_reblog === 1,
follow: row.alert_follow === 1,
follow_request: row.alert_follow_request === 1,
favourite: row.alert_favourite === 1,
poll: row.alert_poll === 1,
update: row.alert_update === 1,
admin_sign_up: row.alert_admin_sign_up === 1,
admin_report: row.alert_admin_report === 1,
},
policy: row.policy,
}
}

Wyświetl plik

@ -112,8 +112,14 @@ export async function getPublicTimeline(
domain: string,
db: D1Database,
localPreference: LocalPreference,
offset: number = 0
offset: number = 0,
hashtag?: string
): Promise<Array<MastodonStatus>> {
let hashtagFilter = ''
if (hashtag) {
hashtagFilter = 'AND note_hashtags.value=?3'
}
const QUERY = `
SELECT objects.*,
actors.id as actor_id,
@ -126,17 +132,24 @@ SELECT objects.*,
FROM outbox_objects
INNER JOIN objects ON objects.id=outbox_objects.object_id
INNER JOIN actors ON actors.id=outbox_objects.actor_id
LEFT JOIN note_hashtags ON objects.id=note_hashtags.object_id
WHERE objects.type='Note'
AND ${localPreferenceQuery(localPreference)}
AND json_extract(objects.properties, '$.inReplyTo') IS NULL
AND outbox_objects.target = '${PUBLIC_GROUP}'
${hashtagFilter}
GROUP BY objects.id
ORDER by outbox_objects.published_date DESC
LIMIT ?1 OFFSET ?2
`
const DEFAULT_LIMIT = 20
const { success, error, results } = await db.prepare(QUERY).bind(DEFAULT_LIMIT, offset).all()
let query = db.prepare(QUERY).bind(DEFAULT_LIMIT, offset)
if (hashtagFilter) {
query = db.prepare(QUERY).bind(DEFAULT_LIMIT, offset, hashtag)
}
const { success, error, results } = await query.all()
if (!success) {
throw new Error('SQL error: ' + error)
}

Wyświetl plik

@ -16,8 +16,9 @@ export function fromObject(obj: APObject): MediaAttachment {
}
}
const imageTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
export function fromObjectDocument(obj: Document): MediaAttachment {
if (obj.mediaType === 'image/jpeg' || obj.mediaType === 'image/png') {
if (imageTypes.includes(obj.mediaType as string)) {
return fromObjectImage(obj)
} else if (obj.mediaType === 'video/mp4') {
return fromObjectVideo(obj)

Wyświetl plik

@ -58,6 +58,9 @@ export async function main(context: EventContext<Env, any, any>) {
url.pathname === '/.well-known/webfinger' ||
url.pathname === '/api/v1/trends/statuses' ||
url.pathname === '/api/v1/trends/links' ||
/^\/api\/v1\/accounts\/(.*)\/statuses$/.test(url.pathname) ||
url.pathname.startsWith('/api/v1/tags/') ||
url.pathname.startsWith('/api/v1/timelines/tag/') ||
url.pathname.startsWith('/ap/') // all ActivityPub endpoints
) {
return context.next()

Wyświetl plik

@ -0,0 +1,6 @@
export type Tag = {
name: string
url: URL
history: Array<void>
following?: boolean
}

Wyświetl plik

@ -0,0 +1,12 @@
/**
* checks if a domain is a localhost one ('localhost' or '127.x.x.x') and
* in that case replaces it with '0.0.0.0' (which is what we use for our local data)
*
* Note: only needed for local development
*
* @param domain the potentially localhost domain
* @returns the adjusted domain if it was a localhost one, the original domain otherwise
*/
export function adjustLocalHostDomain(domain: string) {
return domain.replace(/^localhost$|^127(\.(?:\d){1,3}){3}$/, '0.0.0.0')
}

Wyświetl plik

@ -275,7 +275,7 @@ describe('ActivityPub', () => {
})
describe('Inbox', () => {
test('send Note to non existant user', async () => {
test('send Note to non existent user', async () => {
const db = await makeDB()
const queue = {

Wyświetl plik

@ -1,5 +1,4 @@
import { strict as assert } from 'node:assert/strict'
import type { JWK } from 'wildebeest/backend/src/webpush/jwk'
import type { Env } from 'wildebeest/backend/src/types/env'
import * as v1_instance from 'wildebeest/functions/api/v1/instance'
import * as v2_instance from 'wildebeest/functions/api/v2/instance'
@ -7,24 +6,11 @@ import * as apps from 'wildebeest/functions/api/v1/apps'
import * as custom_emojis from 'wildebeest/functions/api/v1/custom_emojis'
import * as mutes from 'wildebeest/functions/api/v1/mutes'
import * as blocks from 'wildebeest/functions/api/v1/blocks'
import { makeDB, assertCORS, assertJSON, assertCache, createTestClient } from './utils'
import { createPerson } from 'wildebeest/backend/src/activitypub/actors'
import { createSubscription } from '../src/mastodon/subscription'
import * as subscription from 'wildebeest/functions/api/v1/push/subscription'
import { makeDB, assertCORS, assertJSON, assertCache, generateVAPIDKeys } from './utils'
import { enrichStatus } from 'wildebeest/backend/src/mastodon/microformats'
const userKEK = 'test_kek'
const domain = 'cloudflare.com'
async function generateVAPIDKeys(): Promise<JWK> {
const keyPair = (await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [
'sign',
'verify',
])) as CryptoKeyPair
const jwk = (await crypto.subtle.exportKey('jwk', keyPair.privateKey)) as JWK
return jwk
}
describe('Mastodon APIs', () => {
describe('instance', () => {
type Data = {
@ -118,13 +104,14 @@ describe('Mastodon APIs', () => {
assertJSON(res)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { name, website, redirect_uri, client_id, client_secret, vapid_key, ...rest } = await res.json<
const { name, website, redirect_uri, client_id, client_secret, vapid_key, id, ...rest } = await res.json<
Record<string, string>
>()
assert.equal(name, 'Mastodon for iOS')
assert.equal(website, 'https://app.joinmastodon.org/ios')
assert.equal(redirect_uri, 'mastodon://joinmastodon.org/oauth')
assert.equal(id, '20')
assert.deepEqual(rest, {})
})
@ -158,113 +145,6 @@ describe('Mastodon APIs', () => {
})
})
describe('subscriptions', () => {
test('get non existing subscription', async () => {
const db = await makeDB()
const req = new Request('https://example.com')
const client = await createTestClient(db)
const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
const res = await subscription.handleGetRequest(db, req, connectedActor, client.id)
assert.equal(res.status, 404)
})
test('get existing subscription', async () => {
const db = await makeDB()
const req = new Request('https://example.com')
const client = await createTestClient(db)
const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
const data: any = {
subscription: {
endpoint: 'https://endpoint.com',
keys: {
p256dh: 'p256dh',
auth: 'auth',
},
},
data: {
alerts: {},
policy: 'all',
},
}
await createSubscription(db, connectedActor, client, data)
const res = await subscription.handleGetRequest(db, req, connectedActor, client.id)
assert.equal(res.status, 200)
const out = await res.json<any>()
assert.equal(typeof out.id, 'number')
assert.equal(out.endpoint, data.subscription.endpoint)
})
test('create subscription', async () => {
const db = await makeDB()
const client = await createTestClient(db)
const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
const vapidKeys = await generateVAPIDKeys()
const data: any = {
subscription: {
endpoint: 'https://endpoint.com',
keys: {
p256dh: 'p256dh',
auth: 'auth',
},
},
data: {
alerts: {},
policy: 'all',
},
}
const req = new Request('https://example.com', {
method: 'POST',
body: JSON.stringify(data),
})
const res = await subscription.handlePostRequest(db, req, connectedActor, client.id, vapidKeys)
assert.equal(res.status, 200)
const row: any = await db.prepare('SELECT * FROM subscriptions').first()
assert.equal(row.actor_id, connectedActor.id.toString())
assert.equal(row.client_id, client.id)
assert.equal(row.endpoint, data.subscription.endpoint)
})
test('create subscriptions only creates one', async () => {
const db = await makeDB()
const client = await createTestClient(db)
const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
const vapidKeys = await generateVAPIDKeys()
const data: any = {
subscription: {
endpoint: 'https://endpoint.com',
keys: {
p256dh: 'p256dh',
auth: 'auth',
},
},
data: {
alerts: {},
policy: 'all',
},
}
await createSubscription(db, connectedActor, client, data)
const req = new Request('https://example.com', {
method: 'POST',
body: JSON.stringify(data),
})
const res = await subscription.handlePostRequest(db, req, connectedActor, client.id, vapidKeys)
assert.equal(res.status, 200)
const { count } = await db.prepare('SELECT count(*) as count FROM subscriptions').first<{ count: number }>()
assert.equal(count, 1)
})
})
test('mutes returns an empty array', async () => {
const res = await mutes.onRequest()
assert.equal(res.status, 200)

Wyświetl plik

@ -240,5 +240,25 @@ describe('Mastodon APIs', () => {
assert.equal(res.status, 200)
assertCORS(res)
})
test('token handles code in URL', async () => {
const db = await makeDB()
const client = await createTestClient(db, 'https://localhost')
const code = client.id + '.a'
const req = new Request('https://example.com/oauth/token?code=' + code, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: '',
})
const res = await oauth_token.handleRequest(db, req)
assert.equal(res.status, 200)
const data = await res.json<any>()
assert.equal(data.access_token, code)
})
})
})

Wyświetl plik

@ -168,5 +168,12 @@ describe('Mastodon APIs', () => {
assert.equal(data.accounts[1].display_name, 'bar')
}
})
test('empty results for invalid handle', async () => {
const db = await makeDB()
const req = new Request('https://example.com/api/v2/search?q= ')
const res = await search.handleRequest(db, req)
assert.equal(res.status, 400)
})
})
})

Wyświetl plik

@ -966,5 +966,39 @@ describe('Mastodon APIs', () => {
assert.equal(row.count, 1)
}
})
test('hashtag in status adds in note_hashtags table', async () => {
const db = await makeDB()
const queue = makeQueue()
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
const body = {
status: 'hey #hi #car',
visibility: 'public',
}
const req = new Request('https://example.com', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})
const res = await statuses.handleRequest(req, db, actor, userKEK, queue, cache)
assert.equal(res.status, 200)
const data = await res.json<any>()
const { results, success } = await db
.prepare('SELECT value, object_id FROM note_hashtags')
.all<{ value: string; object_id: string }>()
assert(success)
assert(results)
assert.equal(results!.length, 2)
assert.equal(results![0].value, 'hi')
assert.equal(results![1].value, 'car')
const note = (await getObjectByMastodonId(db, data.id)) as unknown as Note
assert.equal(results![0].object_id, note.id.toString())
assert.equal(results![1].object_id, note.id.toString())
})
})
})

Wyświetl plik

@ -0,0 +1,173 @@
import { createSubscription } from '../../src/mastodon/subscription'
import { createPerson } from 'wildebeest/backend/src/activitypub/actors'
import { strict as assert } from 'node:assert/strict'
import { makeDB, createTestClient, generateVAPIDKeys, assertCORS } from '../utils'
import * as subscription from 'wildebeest/functions/api/v1/push/subscription'
const userKEK = 'test_kek21'
const domain = 'cloudflare.com'
describe('Mastodon APIs', () => {
describe('subscriptions', () => {
test('get non existing subscription', async () => {
const db = await makeDB()
const vapidKeys = await generateVAPIDKeys()
const req = new Request('https://example.com')
const client = await createTestClient(db)
const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
const res = await subscription.handleGetRequest(db, req, connectedActor, client.id, vapidKeys)
assert.equal(res.status, 404)
assertCORS(res)
})
test('get existing subscription', async () => {
const db = await makeDB()
const vapidKeys = await generateVAPIDKeys()
const req = new Request('https://example.com')
const client = await createTestClient(db)
const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
const data: any = {
subscription: {
endpoint: 'https://endpoint.com',
keys: {
p256dh: 'p256dh',
auth: 'auth',
},
},
data: {
alerts: {
follow: false,
favourite: true,
reblog: false,
poll: true,
},
policy: 'followed',
},
}
await createSubscription(db, connectedActor, client, data)
const res = await subscription.handleGetRequest(db, req, connectedActor, client.id, vapidKeys)
assert.equal(res.status, 200)
const out = await res.json<any>()
assert.equal(typeof out.id, 'number')
assert.equal(out.endpoint, data.subscription.endpoint)
assert.equal(out.alerts.follow, false)
assert.equal(out.alerts.favourite, true)
assert.equal(out.alerts.reblog, false)
assert.equal(out.alerts.poll, true)
assert.equal(out.policy, 'followed')
})
test('create subscription', async () => {
const db = await makeDB()
const vapidKeys = await generateVAPIDKeys()
const client = await createTestClient(db)
const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
const data: any = {
subscription: {
endpoint: 'https://endpoint.com',
keys: {
p256dh: 'p256dh',
auth: 'auth',
},
},
data: {
alerts: {
poll: false,
status: true,
},
},
}
const req = new Request('https://example.com', {
method: 'POST',
body: JSON.stringify(data),
})
const res = await subscription.handlePostRequest(db, req, connectedActor, client.id, vapidKeys)
assert.equal(res.status, 200)
const out = await res.json<any>()
assert.equal(out.alerts.mention, true)
assert.equal(out.alerts.status, true) // default to true
assert.equal(out.alerts.poll, false)
assert.equal(out.policy, 'all') // default policy
const row: any = await db.prepare('SELECT * FROM subscriptions').first()
assert.equal(row.actor_id, connectedActor.id.toString())
assert.equal(row.client_id, client.id)
assert.equal(row.endpoint, data.subscription.endpoint)
assert.equal(row.alert_poll, 0)
assert.equal(row.alert_mention, 1)
assert.equal(row.alert_status, 1) // default to true
assert.equal(row.policy, 'all') // default policy
})
test('create subscriptions only creates one', async () => {
const db = await makeDB()
const vapidKeys = await generateVAPIDKeys()
const client = await createTestClient(db)
const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
const data: any = {
subscription: {
endpoint: 'https://endpoint.com',
keys: {
p256dh: 'p256dh',
auth: 'auth',
},
},
data: {
alerts: {},
policy: 'all',
},
}
await createSubscription(db, connectedActor, client, data)
const req = new Request('https://example.com', {
method: 'POST',
body: JSON.stringify(data),
})
const res = await subscription.handlePostRequest(db, req, connectedActor, client.id, vapidKeys)
assert.equal(res.status, 200)
const { count } = await db.prepare('SELECT count(*) as count FROM subscriptions').first<{ count: number }>()
assert.equal(count, 1)
})
test('subscriptions auto increment', async () => {
const db = await makeDB()
const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
const data: any = {
subscription: {
endpoint: 'https://endpoint.com',
keys: {
p256dh: 'p256dh',
auth: 'auth',
},
},
data: {
alerts: {},
policy: 'all',
},
}
const client1 = await createTestClient(db)
const sub1 = await createSubscription(db, connectedActor, client1, data)
assert.equal(sub1.id, 1)
const client2 = await createTestClient(db)
const sub2 = await createSubscription(db, connectedActor, client2, data)
assert.equal(sub2.id, 2)
const client3 = await createTestClient(db)
const sub3 = await createSubscription(db, connectedActor, client3, data)
assert.equal(sub3.id, 3)
})
})
})

Wyświetl plik

@ -0,0 +1,36 @@
import { strict as assert } from 'node:assert/strict'
import { createPerson } from 'wildebeest/backend/src/activitypub/actors'
import { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note'
import { makeDB, assertCORS, isUrlValid } from '../utils'
import * as tag_id from 'wildebeest/functions/api/v1/tags/[tag]'
import { insertHashtags } from 'wildebeest/backend/src/mastodon/hashtag'
const domain = 'cloudflare.com'
const userKEK = 'test_kek20'
describe('Mastodon APIs', () => {
describe('tags', () => {
test('return 404 when non existent tag', async () => {
const db = await makeDB()
const res = await tag_id.handleRequestGet(db, domain, 'non-existent-tag')
assertCORS(res)
assert.equal(res.status, 404)
})
test('return tag', async () => {
const db = await makeDB()
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
const note = await createPublicNote(domain, db, 'my localnote status', actor)
await insertHashtags(db, note, ['test'])
const res = await tag_id.handleRequestGet(db, domain, 'test')
assertCORS(res)
assert.equal(res.status, 200)
const data = await res.json<any>()
assert.equal(data.name, 'test')
assert(isUrlValid(data.url))
})
})
})

Wyświetl plik

@ -12,6 +12,7 @@ import * as timelines from 'wildebeest/backend/src/mastodon/timeline'
import { insertLike } from 'wildebeest/backend/src/mastodon/like'
import { insertReblog, createReblog } from 'wildebeest/backend/src/mastodon/reblog'
import { createStatus } from 'wildebeest/backend/src/mastodon/status'
import { insertHashtags } from 'wildebeest/backend/src/mastodon/hashtag'
const userKEK = 'test_kek6'
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
@ -294,5 +295,46 @@ describe('Mastodon APIs', () => {
assert.equal(data.length, 1)
assert.equal(data[0].content, 'a post')
})
test('timeline with non exitent tag', async () => {
const db = await makeDB()
const data = await timelines.getPublicTimeline(
domain,
db,
timelines.LocalPreference.NotSet,
0,
'non-existent-tag'
)
assert.equal(data.length, 0)
})
test('timeline tag', async () => {
const db = await makeDB()
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
{
const note = await createStatus(domain, db, actor, 'test 1')
await insertHashtags(db, note, ['test', 'a'])
}
await sleep(10)
{
const note = await createStatus(domain, db, actor, 'test 2')
await insertHashtags(db, note, ['test', 'b'])
}
{
const data = await timelines.getPublicTimeline(domain, db, timelines.LocalPreference.NotSet, 0, 'test')
assert.equal(data.length, 2)
assert.equal(data[0].content, 'test 2')
assert.equal(data[1].content, 'test 1')
}
{
const data = await timelines.getPublicTimeline(domain, db, timelines.LocalPreference.NotSet, 0, 'a')
assert.equal(data.length, 1)
assert.equal(data[0].content, 'test 1')
}
})
})
})

Wyświetl plik

@ -2,6 +2,7 @@ import { strict as assert } from 'node:assert/strict'
import * as nodeinfo_21 from 'wildebeest/functions/nodeinfo/2.1'
import * as nodeinfo_20 from 'wildebeest/functions/nodeinfo/2.0'
import * as nodeinfo from 'wildebeest/functions/.well-known/nodeinfo'
import { assertCORS } from './utils'
const domain = 'example.com'
@ -9,6 +10,7 @@ describe('NodeInfo', () => {
test('well-known returns links', async () => {
const res = await nodeinfo.handleRequest(domain)
assert.equal(res.status, 200)
assertCORS(res)
const data = await res.json<any>()
assert.equal(data.links.length, 2)
@ -17,6 +19,7 @@ describe('NodeInfo', () => {
test('expose NodeInfo version 2.0', async () => {
const res = await nodeinfo_20.handleRequest()
assert.equal(res.status, 200)
assertCORS(res)
const data = await res.json<any>()
assert.equal(data.version, '2.0')
@ -25,6 +28,7 @@ describe('NodeInfo', () => {
test('expose NodeInfo version 2.1', async () => {
const res = await nodeinfo_21.handleRequest()
assert.equal(res.status, 200)
assertCORS(res)
const data = await res.json<any>()
assert.equal(data.version, '2.1')

Wyświetl plik

@ -1,4 +1,5 @@
import { strict as assert } from 'node:assert/strict'
import type { JWK } from 'wildebeest/backend/src/webpush/jwk'
import type { Cache } from 'wildebeest/backend/src/cache'
import type { Queue } from 'wildebeest/backend/src/types/queue'
import { createClient } from 'wildebeest/backend/src/mastodon/client'
@ -117,3 +118,12 @@ export function isUUID(v: string): boolean {
}
return true
}
export async function generateVAPIDKeys(): Promise<JWK> {
const keyPair = (await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [
'sign',
'verify',
])) as CryptoKeyPair
const jwk = (await crypto.subtle.exportKey('jwk', keyPair.privateKey)) as JWK
return jwk
}

Wyświetl plik

@ -0,0 +1,58 @@
import { makeDB } from '../utils'
import { strict as assert } from 'node:assert/strict'
import { createPerson, getActorById } from 'wildebeest/backend/src/activitypub/actors'
import * as account_alias from 'wildebeest/functions/api/wb/settings/account/alias'
const domain = 'cloudflare.com'
const userKEK = 'test_kek22'
describe('Wildebeest', () => {
describe('Settings', () => {
test('add account alias', async () => {
const db = await makeDB()
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
globalThis.fetch = async (input: RequestInfo) => {
if (input.toString() === 'https://example.com/.well-known/webfinger?resource=acct%3Atest%40example.com') {
return new Response(
JSON.stringify({
links: [
{
rel: 'self',
type: 'application/activity+json',
href: 'https://social.com/someone',
},
],
})
)
}
if (input.toString() === 'https://social.com/someone') {
return new Response(
JSON.stringify({
id: 'https://social.com/someone',
})
)
}
throw new Error('unexpected request to ' + input)
}
const alias = 'test@example.com'
const req = new Request('https://example.com', {
method: 'POST',
body: JSON.stringify({ alias }),
})
const res = await account_alias.handleRequestPost(db, req, actor)
assert.equal(res.status, 201)
// Ensure the actor has the alias set
const newActor = await getActorById(db, actor.id)
assert(newActor)
assert(newActor.alsoKnownAs)
assert.equal(newActor.alsoKnownAs.length, 1)
assert.equal(newActor.alsoKnownAs[0], 'https://social.com/someone')
})
})
})

Wyświetl plik

@ -1,6 +1,5 @@
import type { DefaultImages } from '../backend/src/types/configs'
export const defaultImages: DefaultImages = {
avatar: 'https://masto.ai/avatars/original/missing.png',
avatar: 'https://raw.githubusercontent.com/mastodon/mastodon/main/public/avatars/original/missing.png',
header: 'https://imagedelivery.net/NkfPDviynOyTAOI79ar_GQ/b24caf12-5230-48c4-0bf7-2f40063bd400/header',
}

Wyświetl plik

@ -32,4 +32,26 @@ We will keep optimizing our code to run as fast as possible, but if you start se
After you change your Pages project to Unbound, you need to redeploy it. Go to GitHub Actions in your repo, select the latest successful deploy, and press **Re-run all jobs**.
### WAF false positives
If your zone is on Free plan, you do not need to worry about false positives. We have crafted [free WAF rules](https://blog.cloudflare.com/waf-for-everyone/) covering targeted high severity vulnerabilities.
If your zone is on Pro plan or above **and** you have deployed [WAF managed rules](https://developers.cloudflare.com/waf/managed-rules/) in this zone, there is a chance a few incoming messages are blocked as malicious, such as messages containing code examples. When this happens, you may miss these messages in your feeds. These blocking events can be examined further using [Security Events](https://developers.cloudflare.com/waf/security-events/) where matching [ruleset and rule](https://developers.cloudflare.com/waf/managed-rules/reference/) is logged.
<details>
<summary>Block event example</summary>
![Block event example screenshot](https://imagedelivery.net/NkfPDviynOyTAOI79ar_GQ/ea58f6e2-a320-4322-9b13-9f747f5e6300/public)
</details>
Depending on your existing setup, you can:
- Lower OWASP ruleset's [sensitivity](https://developers.cloudflare.com/waf/managed-rules/reference/owasp-core-ruleset/#configure-in-the-dashboard)
- [Skip](https://developers.cloudflare.com/waf/managed-rules/waf-exceptions/) (part of) managed rules, matching hostname `social.example` which path contains `/ap/users/` and `/inbox`
<details>
<summary>Skip rule example</summary>
![Skip rule example screenshot](https://imagedelivery.net/NkfPDviynOyTAOI79ar_GQ/96370b69-63c9-45a9-2b59-7267bf874c00/public)
</details>
[Index](../README.md) ┊ [Back](other-services.md)

Plik binarny nie jest wyświetlany.

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 159 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 116 KiB

Wyświetl plik

@ -1,10 +1 @@
<svg width="162" height="195" viewBox="0 0 162 195" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M50.516 16.818C42.459 16.56 35.943 16.35 34.125 17.142C-33.9152 46.777 22.745 177.097 28.927 190.796C31.961 184.444 34.155 177.691 35.452 170.809C33.634 169.646 31.721 168.595 29.722 167.664C31.735 167.449 33.781 167.339 35.855 167.339C41.503 167.339 46.951 168.157 52.075 169.678C48.405 178.982 42.02 187.018 33.841 192.908C40.675 194.194 53.94 195.898 62.169 191.835C67.18 179.224 67.87 165.663 64.212 153.538C64.959 152.383 66.055 151.942 67.932 152.255C70.391 156.944 72.163 161.909 73.243 166.997L104.363 160.483C105.856 153.071 105.691 145.548 103.86 138.529C104.88 137.832 105.921 137.178 106.981 136.567C110.478 143.538 112.224 151.188 112.19 158.845L116.201 158.005C120.391 147.691 121.185 136.607 118.562 126.555C119.583 125.858 120.624 125.204 121.684 124.593C126.57 134.332 128.036 145.396 126.011 155.952L147.228 151.511C155.567 149.765 161.54 142.412 161.54 133.892V27.399C161.54 8.45 141.01 -4.08 123.408 2.936C111.684 7.608 98.336 12.261 84.984 15.531C76.342 17.647 61.892 17.183 50.516 16.818ZM132.269 38.806C126.328 37.807 120.137 37.676 113.854 38.53C94.93 41.101 79.206 52.06 69.922 67.148C77.435 59.061 87.688 53.427 99.479 51.825C104.554 51.136 109.554 51.242 114.352 52.048C111.372 65.079 103.189 76.382 91.823 83.301C112.337 76.527 127.558 59.41 132.269 38.806Z" fill="url(#paint0_linear_0_1)"/>
<path d="M25 53V110.25H25.029C25.029 120.499 27.585 128.562 32.727 134.587C38.044 140.583 45.007 143.654 53.615 143.655C63.634 143.655 71.184 139.726 76.179 131.959L81.055 123.689L85.933 131.959C90.927 139.756 98.507 143.655 108.496 143.655C117.104 143.655 124.067 140.612 129.385 134.587C134.526 128.562 137.082 120.44 137.082 110.25V53L117.574 65.1582V108.773C117.574 118.993 113.314 124.161 104.853 124.161C95.422 124.161 90.751 118.048 90.751 105.85V79.298H71.36V105.85C71.36 117.988 66.63 124.161 57.258 124.161C48.768 124.161 44.537 118.993 44.537 108.773V65.1288L25 53Z" fill="white"/>
<defs>
<linearGradient id="paint0_linear_0_1" x1="105.159" y1="151.638" x2="110.686" y2="4.301" gradientUnits="userSpaceOnUse">
<stop stop-color="#563ACC"/>
<stop offset="1" stop-color="#6364FF"/>
</linearGradient>
</defs>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="162" height="195" fill="none" viewBox="0 0 162 195"><path fill="url(#paint0_linear_0_1)" fill-rule="evenodd" d="M50.516 16.818C42.459 16.56 35.943 16.35 34.125 17.142C-33.9152 46.777 22.745 177.097 28.927 190.796C31.961 184.444 34.155 177.691 35.452 170.809C33.634 169.646 31.721 168.595 29.722 167.664C31.735 167.449 33.781 167.339 35.855 167.339C41.503 167.339 46.951 168.157 52.075 169.678C48.405 178.982 42.02 187.018 33.841 192.908C40.675 194.194 53.94 195.898 62.169 191.835C67.18 179.224 67.87 165.663 64.212 153.538C64.959 152.383 66.055 151.942 67.932 152.255C70.391 156.944 72.163 161.909 73.243 166.997L104.363 160.483C105.856 153.071 105.691 145.548 103.86 138.529C104.88 137.832 105.921 137.178 106.981 136.567C110.478 143.538 112.224 151.188 112.19 158.845L116.201 158.005C120.391 147.691 121.185 136.607 118.562 126.555C119.583 125.858 120.624 125.204 121.684 124.593C126.57 134.332 128.036 145.396 126.011 155.952L147.228 151.511C155.567 149.765 161.54 142.412 161.54 133.892V27.399C161.54 8.45 141.01 -4.08 123.408 2.936C111.684 7.608 98.336 12.261 84.984 15.531C76.342 17.647 61.892 17.183 50.516 16.818ZM132.269 38.806C126.328 37.807 120.137 37.676 113.854 38.53C94.93 41.101 79.206 52.06 69.922 67.148C77.435 59.061 87.688 53.427 99.479 51.825C104.554 51.136 109.554 51.242 114.352 52.048C111.372 65.079 103.189 76.382 91.823 83.301C112.337 76.527 127.558 59.41 132.269 38.806Z" clip-rule="evenodd"/><path fill="#fff" d="M25 53V110.25H25.029C25.029 120.499 27.585 128.562 32.727 134.587C38.044 140.583 45.007 143.654 53.615 143.655C63.634 143.655 71.184 139.726 76.179 131.959L81.055 123.689L85.933 131.959C90.927 139.756 98.507 143.655 108.496 143.655C117.104 143.655 124.067 140.612 129.385 134.587C134.526 128.562 137.082 120.44 137.082 110.25V53L117.574 65.1582V108.773C117.574 118.993 113.314 124.161 104.853 124.161C95.422 124.161 90.751 118.048 90.751 105.85V79.298H71.36V105.85C71.36 117.988 66.63 124.161 57.258 124.161C48.768 124.161 44.537 118.993 44.537 108.773V65.1288L25 53Z"/><defs><linearGradient id="paint0_linear_0_1" x1="105.159" x2="110.686" y1="151.638" y2="4.301" gradientUnits="userSpaceOnUse"><stop stop-color="#563ACC"/><stop offset="1" stop-color="#6364FF"/></linearGradient></defs></svg>

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 2.2 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 2.2 KiB

Wyświetl plik

@ -11,10 +11,14 @@ export const ImagesModal = component$<Props>(({ images, idxOfCurrentImage: initi
const idxOfCurrentImage = useSignal(initialIdx)
return (
<div class="pointer-events-auto cursor-default z-50 fixed inset-0 isolate flex items-center justify-between backdrop-blur-sm">
<div
data-testid="images-modal"
class="pointer-events-auto cursor-default z-50 fixed inset-0 isolate flex items-center justify-between backdrop-blur-sm"
>
<div class="inset-0 absolute z-[-1] bg-wildebeest-900 opacity-70" onClick$={() => onCloseImagesModal$()}></div>
{images.length > 1 && (
<button
data-testid="left-btn"
class="cursor-pointer text-4xl opacity-60 hover:opacity-90 focus-visible:opacity-90"
onClick$={() => {
const idx = idxOfCurrentImage.value - 1
@ -27,6 +31,7 @@ export const ImagesModal = component$<Props>(({ images, idxOfCurrentImage: initi
<img class="ma max-w-[80vw] max-h-[90vh] m-auto" src={images[idxOfCurrentImage.value].url} />
{images.length > 1 && (
<button
data-testid="right-btn"
class="cursor-pointer text-4xl opacity-60 hover:opacity-90 focus-visible:opacity-90"
onClick$={() => {
idxOfCurrentImage.value = (idxOfCurrentImage.value + 1) % images.length
@ -36,6 +41,7 @@ export const ImagesModal = component$<Props>(({ images, idxOfCurrentImage: initi
</button>
)}
<button
data-testid="close-btn"
class="cursor-pointer absolute top-7 right-7 text-4xl opacity-60 hover:opacity-90 focus-visible:opacity-90"
onClick$={() => onCloseImagesModal$()}
>

Wyświetl plik

@ -34,7 +34,10 @@ export const MediaGallery = component$<Props>(({ medias }) => {
return (
<>
{!!medias.length && (
<div class={`media-gallery overflow-hidden grid gap-1 h-52 md:h-60 lg:h-72 xl:h-80`}>
<div
data-testid="media-gallery"
class={`media-gallery overflow-hidden grid gap-1 h-52 md:h-60 lg:h-72 xl:h-80`}
>
{medias.map((media) => (
<div class="w-full flex items-center justify-center overflow-hidden bg-black">
{media.type === 'image' && <Image mediaAttachment={media} onOpenImagesModal$={onOpenImagesModal} />}

Wyświetl plik

@ -5,6 +5,8 @@ import { Avatar } from '../avatar'
import type { Account, MastodonStatus } from '~/types'
import styles from '../../utils/innerHtmlContent.scss?inline'
import { MediaGallery } from '../MediaGallery.tsx'
import { useAccountUrl } from '~/utils/useAccountUrl'
import { getDisplayNameElement } from '~/utils/getDisplayNameElement'
type Props = {
status: MastodonStatus
@ -17,13 +19,13 @@ export default component$((props: Props) => {
const status = props.status.reblog ?? props.status
const reblogger = props.status.reblog && props.status.account
const accountUrl = `/@${status.account.username}`
const accountUrl = useAccountUrl(status.account)
const statusUrl = `${accountUrl}/${status.id}`
const handleContentClick = $(() => nav(statusUrl))
return (
<article class="p-4 border-t border-wildebeest-700 pointer">
<article class="p-4 border-t border-wildebeest-700 break-words sm:break-normal">
<RebloggerLink account={reblogger}></RebloggerLink>
<div onClick$={handleContentClick}>
<div class="flex justify-between mb-3">
@ -32,7 +34,7 @@ export default component$((props: Props) => {
<div class="flex-col ml-3">
<div>
<Link class="no-underline" href={accountUrl}>
{status.account.display_name}
{getDisplayNameElement(status.account)}
</Link>
</div>
<div class="text-wildebeest-500">@{status.account.username}</div>
@ -65,18 +67,20 @@ export default component$((props: Props) => {
)
})
export const RebloggerLink = ({ account }: { account: Account | null }) => {
export const RebloggerLink = component$(({ account }: { account: Account | null }) => {
const accountUrl = useAccountUrl(account)
return (
account && (
<div class="flex text-wildebeest-500 py-3">
<p>
<i class="fa fa-retweet mr-3 w-4 inline-block" />
<a class="no-underline" href={account.url}>
{account.display_name}
<a class="no-underline" href={accountUrl}>
{getDisplayNameElement(account)}
</a>
&nbsp;boosted
</p>
</div>
)
)
}
})

Wyświetl plik

@ -0,0 +1,62 @@
import { $, component$, useClientEffect$, useSignal, type QRL } from '@builder.io/qwik'
import { type MastodonStatus } from '~/types'
import Status from '../Status'
type Props = {
initialStatuses: MastodonStatus[]
fetchMoreStatuses: QRL<(numOfCurrentStatuses: number) => Promise<MastodonStatus[]>>
}
export const StatusesPanel = component$(({ initialStatuses, fetchMoreStatuses: fetchMoreStatusesFn }: Props) => {
const fetchingMoreStatuses = useSignal(false)
const noMoreStatusesAvailable = useSignal(false)
const lastStatusRef = useSignal<HTMLDivElement>()
const statuses = useSignal<MastodonStatus[]>(initialStatuses)
const fetchMoreStatuses = $(async () => {
if (fetchingMoreStatuses.value || noMoreStatusesAvailable.value) {
return
}
fetchingMoreStatuses.value = true
const newStatuses = await fetchMoreStatusesFn(statuses.value.length)
fetchingMoreStatuses.value = false
noMoreStatusesAvailable.value = newStatuses.length === 0
statuses.value = [...statuses.value, ...newStatuses]
})
useClientEffect$(({ track }) => {
track(() => lastStatusRef.value)
if (lastStatusRef.value) {
const observer = new IntersectionObserver(
async ([lastStatus]) => {
if (lastStatus.isIntersecting) {
await fetchMoreStatuses()
observer.disconnect()
}
},
{ rootMargin: '250px' }
)
observer.observe(lastStatusRef.value)
}
})
return (
<>
{statuses.value.length > 0 ? (
statuses.value.map((status, i) => {
const isLastStatus = i === statuses.value.length - 1
const divProps = isLastStatus ? { ref: lastStatusRef } : {}
return (
<div key={status.id} {...divProps}>
<Status status={status} />
</div>
)
})
) : (
<div class="flex-1 grid place-items-center bg-wildebeest-600 text-center">
<p>Nothing to see right now. Check back later!</p>
</div>
)}
</>
)
})

Wyświetl plik

@ -1,7 +1,8 @@
import { component$ } from '@builder.io/qwik'
import type { Account } from '~/types'
import { useAccountUrl } from '~/utils/useAccountUrl'
type AvatarDetails = Pick<Account, 'display_name' | 'avatar' | 'url'>
type AvatarDetails = Partial<Pick<Account, 'id'>> & Pick<Account, 'display_name' | 'avatar' | 'url'>
type Props = {
primary: AvatarDetails
@ -9,13 +10,16 @@ type Props = {
}
export const Avatar = component$<Props>(({ primary, secondary }) => {
const primaryUrl = useAccountUrl(primary)
const secondaryUrl = useAccountUrl(secondary)
return (
<div class={`relative ${secondary && 'pr-2 pb-2'}`}>
<a href={primary.url}>
<a href={primaryUrl}>
<img class="rounded h-12 w-12" src={primary.avatar} alt={`Avatar of ${primary.display_name}`} />
</a>
{secondary && (
<a href={secondary.url}>
<a href={secondaryUrl}>
<img
class="absolute right-0 bottom-0 rounded h-6 w-6"
src={secondary.avatar}

Plik diff jest za duży Load Diff

Wyświetl plik

@ -0,0 +1,106 @@
import { type Account } from '~/types'
import { getRandomDateInThePastYear } from './getRandomDateInThePastYear'
export const george = generateDummyAccount({
username: 'george',
acct: 'george_george@dummy.users.wildebeest.com',
display_name: 'George :verified: 👍',
avatar: getAvatarUrl(805),
avatar_static: getAvatarUrl(805),
})
export const zak = generateDummyAccount({
username: 'ZakSmith',
acct: 'ZakSmith',
display_name: 'Zak Smith',
avatar: getAvatarUrl(75),
avatar_static: getAvatarUrl(75),
})
export const penny = generateDummyAccount({
username: 'Penny',
acct: 'Penny',
display_name: 'Penny',
avatar: getAvatarUrl(140),
avatar_static: getAvatarUrl(140),
})
export const ben = generateDummyAccount({
username: 'Ben',
acct: 'ben',
display_name: 'Ben, just Ben',
avatar: getAvatarUrl(1148),
avatar_static: getAvatarUrl(1148),
})
export const rafael = generateDummyAccount({
username: 'Rafael',
acct: 'raffa',
display_name: 'Raffa123$',
avatar: getAvatarUrl(157),
avatar_static: getAvatarUrl(309),
})
function generateDummyAccount(
details: Pick<Account, 'username' | 'acct' | 'display_name' | 'avatar' | 'avatar_static'>
): Account {
return {
...details,
id: `${Math.round(Math.random() * 9999999)}`.padStart(7, '0'),
locked: false,
bot: false,
discoverable: true,
group: false,
created_at: getRandomDateInThePastYear().toISOString(),
note: '<p>A simple note!</p>',
url: `https://dummay.users.wildebeest.com/@${details.username}`,
header: getRandomHeaderUrl(),
header_static: getRandomHeaderUrl(),
followers_count: Math.round(Math.random() * 100),
following_count: Math.round(Math.random() * 100),
statuses_count: Math.round(Math.random() * 100),
last_status_at: getLastStatusAt(),
emojis: [
{
shortcode: 'verified',
url: 'https://files.mastodon.social/cache/custom_emojis/images/000/452/462/original/947cae7ac4dfdfa0.png',
static_url: 'https://files.mastodon.social/cache/custom_emojis/images/000/452/462/static/947cae7ac4dfdfa0.png',
visible_in_picker: true,
},
],
fields: [
{
name: 'Instagram',
value:
'<a href="https://www.instagram.com/" rel="nofollow noopener noreferrer" target="_blank"><span class="invisible">https://www.</span><span class="">instagram.com</span><span class="invisible"></span></a>',
verified_at: null,
},
{
name: 'Twitter',
value:
'<a href="https://twitter.com/" rel="nofollow noopener noreferrer" target="_blank"><span class="invisible">https://</span><span class="">twitter.com</span><span class="invisible"></span></a>',
verified_at: null,
},
{
name: 'Facebook',
value:
'<a href="https://www.facebook.com/" rel="nofollow noopener noreferrer" target="_blank"><span class="invisible">https://www.</span><span class="">facebook.com</span><span class="invisible"></span></a>',
verified_at: null,
},
],
}
}
// the number should be between 0 and 1249
function getAvatarUrl(number: number) {
return `https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/${number}.jpg`
}
function getRandomHeaderUrl() {
return `https:/loremflickr.com/640/480/wildebeest?lock=${Math.round(Math.random() * 999999)}`
}
function getLastStatusAt() {
const date = getRandomDateInThePastYear()
return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
}

Wyświetl plik

@ -0,0 +1,36 @@
import { Account, MastodonStatus, MediaAttachment } from '~/types'
import { getRandomDateInThePastYear } from './getRandomDateInThePastYear'
export function generateDummyStatus(
content: string,
account: Account,
mediaAttachments: MediaAttachment[] = [],
inReplyTo: string | null = null
): MastodonStatus {
return {
id: `${Math.random() * 9999999}`.padStart(3, '7'),
created_at: getRandomDateInThePastYear().toISOString(),
in_reply_to_id: inReplyTo,
in_reply_to_account_id: null,
sensitive: false,
spoiler_text: '',
visibility: 'public',
language: 'en',
uri: '',
url: '',
replies_count: 0,
reblogs_count: 0,
favourites_count: Math.random() * 900,
edited_at: null,
content,
reblog: null,
application: { name: 'Wildebeest', website: null },
account,
media_attachments: mediaAttachments,
mentions: [],
tags: [],
emojis: [],
card: null,
poll: null,
}
}

Wyświetl plik

@ -0,0 +1,11 @@
export function getRandomDateInThePastYear(): Date {
const nowDate = new Date()
const pastDate = new Date(nowDate.getFullYear() - 1, nowDate.getMonth(), nowDate.getDate())
const pastDateInMillis = pastDate.getTime()
const nowDateInMillis = nowDate.getTime()
const random = Math.random()
const randomDateInMillis = pastDateInMillis + random * (nowDateInMillis - pastDateInMillis)
return new Date(randomDateInMillis)
}

Wyświetl plik

@ -0,0 +1,3 @@
export * from './statuses'
export * from './tags'
export * from './links'

Wyświetl plik

@ -0,0 +1,259 @@
import { MastodonLink } from '../types'
export const links: Array<MastodonLink> = [
{
url: 'https://www.forbes.com/sites/emilybaker-white/2022/12/22/tiktok-tracks-forbes-journalists-bytedance/',
title: 'EXCLUSIVE: TikTok Spied On Forbes Journalists',
description:
'ByteDance confirmed it used TikTok to monitor journalists physical location using their IP addresses, as first reported by Forbes in October.',
type: 'link',
author_name: 'Emily Baker-White',
author_url: 'https://www.forbes.com/sites/emilybaker-white/',
provider_name: 'Forbes',
provider_url: '',
html: '',
width: 400,
height: 161,
image: 'https://files.mastodon.social/cache/preview_cards/images/050/524/205/original/471cd0adc1ee6409.jpg',
embed_url: '',
blurhash: 'UICsHx$*0Ko}w{RPnix]E1NZ-;s:gNkWRjM{',
history: [
{ day: '1671753600', accounts: '1503', uses: '1657' },
{ day: '1671667200', accounts: '1537', uses: '1727' },
{ day: '1671580800', accounts: '0', uses: '0' },
{ day: '1671494400', accounts: '0', uses: '0' },
{ day: '1671408000', accounts: '0', uses: '0' },
{ day: '1671321600', accounts: '0', uses: '0' },
{ day: '1671235200', accounts: '0', uses: '0' },
],
},
{
url: 'https://www.theverge.com/2022/12/22/23523322/lastpass-data-breach-cloud-encrypted-password-vault-hackers',
title: 'Hackers stole encrypted LastPass password vaults, and were just now hearing about it',
description:
'LastPass has announced that during a November data breach of its cloud storage, hackers copied a backup of customer vault data that includes encrypted usernames and passwords.',
type: 'link',
author_name: 'Mitchell Clark',
author_url: '',
provider_name: 'The Verge',
provider_url: '',
html: '',
width: 400,
height: 209,
image: 'https://files.mastodon.social/cache/preview_cards/images/050/539/308/original/64c8d7b28cfaad2f.jpg',
embed_url: '',
blurhash: 'UfF;eEs,FRSiD^ajbXWXtvbb#hslECWVs%oL',
history: [
{ day: '1671753600', accounts: '282', uses: '291' },
{ day: '1671667200', accounts: '0', uses: '0' },
{ day: '1671580800', accounts: '0', uses: '0' },
{ day: '1671494400', accounts: '0', uses: '0' },
{ day: '1671408000', accounts: '0', uses: '0' },
{ day: '1671321600', accounts: '0', uses: '0' },
{ day: '1671235200', accounts: '0', uses: '0' },
],
},
{
url: 'https://arstechnica.com/tech-policy/2022/12/facial-recognition-flags-girl-scout-mom-as-security-risk-at-rockettes-show/',
title: 'MSG defends using facial recognition to kick lawyer out of Rockettes show',
description: 'MSG Entertainment began using facial recognition at venues in 2018.',
type: 'link',
author_name: '',
author_url: '',
provider_name: 'Ars Technica',
provider_url: '',
html: '',
width: 400,
height: 267,
image: 'https://files.mastodon.social/cache/preview_cards/images/050/356/463/original/3d7c9f4eb7630b3a.jpg',
embed_url: '',
blurhash: 'ULBV@rQ+R%krPWR4n$o#1JV@oJof#RofbHWB',
history: [
{ day: '1671753600', accounts: '704', uses: '705' },
{ day: '1671667200', accounts: '3191', uses: '3228' },
{ day: '1671580800', accounts: '48', uses: '51' },
{ day: '1671494400', accounts: '17', uses: '17' },
{ day: '1671408000', accounts: '0', uses: '0' },
{ day: '1671321600', accounts: '0', uses: '0' },
{ day: '1671235200', accounts: '0', uses: '0' },
],
},
{
url: 'https://www.theatlantic.com/ideas/archive/2022/12/zelensky-congress-speech-us-ukraine-support/672547/',
title: 'The Brutal Alternate World in Which the U.S. Abandoned Ukraine',
description: 'Ukrainian resistance and American support prevented a wide range of horrors.',
type: 'link',
author_name: 'Anne Applebaum',
author_url: '',
provider_name: 'The Atlantic',
provider_url: '',
html: '',
width: 400,
height: 208,
image: 'https://files.mastodon.social/cache/preview_cards/images/050/495/789/original/a464705b4eba4614.jpg',
embed_url: '',
blurhash: 'U14B:,4oW9M_Roavt8ax4nxt%NRjRhohayax',
history: [
{ day: '1671753600', accounts: '113', uses: '117' },
{ day: '1671667200', accounts: '441', uses: '452' },
{ day: '1671580800', accounts: '0', uses: '0' },
{ day: '1671494400', accounts: '0', uses: '0' },
{ day: '1671408000', accounts: '0', uses: '0' },
{ day: '1671321600', accounts: '0', uses: '0' },
{ day: '1671235200', accounts: '0', uses: '0' },
],
},
{
url: 'https://www.washingtonpost.com/national-security/2022/12/22/jan-6-committee-trump-should-never-hold-office-again/',
title: 'Jan. 6 committee: Trump should never hold office again',
description:
'The committees recommendation came as part of an 800-plus page report that marks the culmination of its 18-month investigation',
type: 'link',
author_name: 'Amy Gardner',
author_url: 'https://www.washingtonpost.com/people/amy-gardner/',
provider_name: 'The Washington Post',
provider_url: '',
html: '',
width: 400,
height: 267,
image: 'https://files.mastodon.social/cache/preview_cards/images/050/548/842/original/5a159d17dc4b084a.jpeg',
embed_url: '',
blurhash: 'UKE.kHxaI9RO~TofMwn#SiWXNHWBIAWVtSoz',
history: [
{ day: '1671753600', accounts: '116', uses: '119' },
{ day: '1671667200', accounts: '0', uses: '0' },
{ day: '1671580800', accounts: '0', uses: '0' },
{ day: '1671494400', accounts: '0', uses: '0' },
{ day: '1671408000', accounts: '0', uses: '0' },
{ day: '1671321600', accounts: '0', uses: '0' },
{ day: '1671235200', accounts: '0', uses: '0' },
],
},
{
url: 'https://www.washingtonpost.com/media/2022/12/23/musk-twitter-journalists-suspended-elonjet/',
title: 'Journalists who wont delete Musk tweets remain locked out of Twitter',
description:
'Musk suspended reporters from Twitter and later reinstated them, with a catch: They must delete tweets related to the account @ElonJet, which has tracked Musks plane using public data.',
type: 'link',
author_name: 'Paul Farhi',
author_url: 'https://www.washingtonpost.com/people/paul-farhi/',
provider_name: 'The Washington Post',
provider_url: '',
html: '',
width: 400,
height: 267,
image: 'https://files.mastodon.social/cache/preview_cards/images/050/596/043/original/699238391e3433a5.jpeg',
embed_url: '',
blurhash: 'UA8gjY9DEMJDxUjdInNeIoNG-p$y9tt7xaxZ',
history: [
{ day: '1671753600', accounts: '119', uses: '120' },
{ day: '1671667200', accounts: '0', uses: '0' },
{ day: '1671580800', accounts: '0', uses: '0' },
{ day: '1671494400', accounts: '0', uses: '0' },
{ day: '1671408000', accounts: '0', uses: '0' },
{ day: '1671321600', accounts: '0', uses: '0' },
{ day: '1671235200', accounts: '0', uses: '0' },
],
},
{
url: 'https://www.reuters.com/legal/facebook-parent-meta-pay-725-mln-settle-lawsuit-relating-cambridge-analytica-2022-12-23/',
title: 'Facebook parent Meta to settle Cambridge Analytica scandal case for $725 mln',
description:
'Facebook owner Meta Platforms Inc \u003ca href="https://www.reuters.com/companies/META.O" target="_blank"\u003e(META.O)\u003c/a\u003e has agreed to pay $725 million to resolve a class-action lawsuit accusing the social media giant of allowing third parties, including Cambridge Analytica, to access users\' personal information.',
type: 'link',
author_name: '',
author_url: '',
provider_name: 'Reuters',
provider_url: '',
html: '',
width: 400,
height: 209,
image: 'https://files.mastodon.social/cache/preview_cards/images/050/566/114/original/1d0630d4cf841790.jpg',
embed_url: '',
blurhash: 'UPNKbeMwMH?GWBWAog.8McofR*%NNHIUof-;',
history: [
{ day: '1671753600', accounts: '92', uses: '97' },
{ day: '1671667200', accounts: '0', uses: '0' },
{ day: '1671580800', accounts: '0', uses: '0' },
{ day: '1671494400', accounts: '0', uses: '0' },
{ day: '1671408000', accounts: '0', uses: '0' },
{ day: '1671321600', accounts: '0', uses: '0' },
{ day: '1671235200', accounts: '0', uses: '0' },
],
},
{
url: 'https://www.bloomberg.com/news/articles/2022-12-22/musk-s-frequent-twitter-polls-are-at-risk-of-bot-manipulation',
title: 'Musks Frequent Twitter Polls Are at Risk of Bot Manipulation',
description: 'New research shows votes can be easily purchased during Twitter polls',
type: 'link',
author_name: 'Davey Alba',
author_url: '',
provider_name: 'Bloomberg',
provider_url: '',
html: '',
width: 400,
height: 267,
image: 'https://files.mastodon.social/cache/preview_cards/images/050/514/000/original/1bf993cf6612abd8.jpg',
embed_url: '',
blurhash: 'UHAfCIofo~o#ysWYeRWCDibHWAj?t8j@o}of',
history: [
{ day: '1671753600', accounts: '23', uses: '24' },
{ day: '1671667200', accounts: '170', uses: '196' },
{ day: '1671580800', accounts: '0', uses: '0' },
{ day: '1671494400', accounts: '0', uses: '0' },
{ day: '1671408000', accounts: '0', uses: '0' },
{ day: '1671321600', accounts: '0', uses: '0' },
{ day: '1671235200', accounts: '0', uses: '0' },
],
},
{
url: 'https://www.wsj.com/articles/u-s-life-expectancy-fell-to-lowest-level-since-1996-11671667059',
title: 'U.S. Life Expectancy Fell to Lowest Level Since 1996',
description: 'Covid-19 and opioid overdoses contributed to a 5% rise in death rate last year',
type: 'link',
author_name: 'Julie Wernau',
author_url: '',
provider_name: 'The Wall Street Journal',
provider_url: '',
html: '',
width: 400,
height: 200,
image: 'https://files.mastodon.social/cache/preview_cards/images/050/485/157/original/3668ba0bbaf09ebc.jpeg',
embed_url: '',
blurhash: 'UkMDZ69^v$}qrYkCo|M}OYw^I:OXRjj[t7jF',
history: [
{ day: '1671753600', accounts: '31', uses: '31' },
{ day: '1671667200', accounts: '165', uses: '168' },
{ day: '1671580800', accounts: '0', uses: '0' },
{ day: '1671494400', accounts: '0', uses: '0' },
{ day: '1671408000', accounts: '0', uses: '0' },
{ day: '1671321600', accounts: '0', uses: '0' },
{ day: '1671235200', accounts: '0', uses: '0' },
],
},
{
url: 'https://www.wired.com/story/email-scam-dicks-sporting-goods-yeti-cooler/',
title: 'No, You Havent Won a Yeti Cooler From Dicks Sporting Goods',
description: 'The future of email spam utilizes a coding trick that evades the most sophisticated detection tools.',
type: 'link',
author_name: 'Lauren Goode',
author_url: '',
provider_name: 'WIRED',
provider_url: '',
html: '',
width: 400,
height: 209,
image: 'https://files.mastodon.social/cache/preview_cards/images/050/574/335/original/e10dbfa75b92a64a.jpg',
embed_url: '',
blurhash: 'UMBVv7#GI_OTV$-Lx9V|5eSw$dxDtJEoJEX7',
history: [
{ day: '1671753600', accounts: '70', uses: '72' },
{ day: '1671667200', accounts: '0', uses: '0' },
{ day: '1671580800', accounts: '0', uses: '0' },
{ day: '1671494400', accounts: '0', uses: '0' },
{ day: '1671408000', accounts: '0', uses: '0' },
{ day: '1671321600', accounts: '0', uses: '0' },
{ day: '1671235200', accounts: '0', uses: '0' },
],
},
]

Wyświetl plik

@ -0,0 +1,75 @@
import type { MediaAttachment, MastodonStatus } from '~/types'
import { generateDummyStatus } from './generateDummyStatus'
import { ben, george, penny, rafael, zak } from './accounts'
// Raw statuses taken directly from mastodon
const mastodonRawStatuses: MastodonStatus[] = [
generateDummyStatus("<p>Fine. I'll use Wildebeest!</p>", george),
generateDummyStatus('We did it!', george, [
generateDummyMediaImage(`https:/loremflickr.com/640/480/victory?lock=${Math.round(Math.random() * 999999)}`),
]),
generateDummyStatus('<span>A very simple update: all good!</span>', ben),
generateDummyStatus('<p>Hi! My name is Rafael! 👋</p>', rafael),
generateDummyStatus(
"<div><p>I'm Rafael and I am a web designer!</p><p>💪💪</p></div>",
rafael,
new Array(4)
.fill(null)
.map((_, idx) => generateDummyMediaImage(`https:/loremflickr.com/640/480/abstract?lock=${100 + idx}`))
),
]
export const statuses: MastodonStatus[] = mastodonRawStatuses.map((rawStatus) => ({
...rawStatus,
media_attachments: rawStatus.media_attachments.map((mediaAttachment) => ({
...mediaAttachment,
type: getStandardMediaType(mediaAttachment.type),
})),
}))
export const replies: MastodonStatus[] = [
generateDummyStatus('<p>Yes we did! 🎉</p>', zak, [], statuses[1].id),
generateDummyStatus('<p> Yes you guys did it! </p>', penny, [], statuses[1].id),
]
function getStandardMediaType(mediaAttachmentMastodonType: string): string {
switch (mediaAttachmentMastodonType) {
case 'image':
return 'Image'
case 'video':
return 'Video'
}
return mediaAttachmentMastodonType
}
function generateDummyMediaImage(imageUrl: string): MediaAttachment {
return {
id: `${Math.random() * 9999999}`.padStart(3, '7'),
type: 'image',
url: imageUrl,
preview_url: imageUrl,
remote_url: null,
preview_remote_url: null,
text_url: null,
meta: {
original: {
width: 1821,
height: 1138,
size: '1821x1138',
aspect: 1.6001757469244289,
},
small: {
width: 606,
height: 379,
size: '606x379',
aspect: 1.5989445910290236,
},
focus: {
x: 0.0,
y: 0.0,
},
},
description: 'A dummy image',
blurhash: '',
}
}

Wyświetl plik

@ -0,0 +1,134 @@
import { type TagDetails } from '../types'
export const tags: TagDetails[] = [
{
name: 'wintersolstice',
url: 'https://mastodon.social/tags/wintersolstice',
history: [
{ day: '1671580800', accounts: '951', uses: '1113' },
{ day: '1671494400', accounts: '59', uses: '75' },
{ day: '1671408000', accounts: '24', uses: '25' },
{ day: '1671321600', accounts: '19', uses: '21' },
{ day: '1671235200', accounts: '13', uses: '16' },
{ day: '1671148800', accounts: '13', uses: '13' },
{ day: '1671062400', accounts: '8', uses: '8' },
],
},
{
name: 'solstice',
url: 'https://mastodon.social/tags/solstice',
history: [
{ day: '1671580800', accounts: '954', uses: '1114' },
{ day: '1671494400', accounts: '80', uses: '118' },
{ day: '1671408000', accounts: '19', uses: '22' },
{ day: '1671321600', accounts: '24', uses: '28' },
{ day: '1671235200', accounts: '14', uses: '14' },
{ day: '1671148800', accounts: '9', uses: '9' },
{ day: '1671062400', accounts: '11', uses: '11' },
],
},
{
name: 'transitiontuesday',
url: 'https://mastodon.social/tags/transitiontuesday',
history: [
{ day: '1671580800', accounts: '92', uses: '103' },
{ day: '1671494400', accounts: '0', uses: '0' },
{ day: '1671408000', accounts: '0', uses: '0' },
{ day: '1671321600', accounts: '0', uses: '0' },
{ day: '1671235200', accounts: '0', uses: '0' },
{ day: '1671148800', accounts: '0', uses: '0' },
{ day: '1671062400', accounts: '0', uses: '0' },
],
},
{
name: 'mustardmovies',
url: 'https://mastodon.social/tags/mustardmovies',
history: [
{ day: '1671580800', accounts: '88', uses: '437' },
{ day: '1671494400', accounts: '0', uses: '0' },
{ day: '1671408000', accounts: '0', uses: '0' },
{ day: '1671321600', accounts: '0', uses: '0' },
{ day: '1671235200', accounts: '0', uses: '0' },
{ day: '1671148800', accounts: '0', uses: '0' },
{ day: '1671062400', accounts: '0', uses: '0' },
],
},
{
name: 'oddesttrumptaxdeductions',
url: 'https://mastodon.social/tags/oddesttrumptaxdeductions',
history: [
{ day: '1671580800', accounts: '73', uses: '113' },
{ day: '1671494400', accounts: '0', uses: '0' },
{ day: '1671408000', accounts: '0', uses: '0' },
{ day: '1671321600', accounts: '0', uses: '0' },
{ day: '1671235200', accounts: '0', uses: '0' },
{ day: '1671148800', accounts: '0', uses: '0' },
{ day: '1671062400', accounts: '0', uses: '0' },
],
},
{
name: 'wintersonnenwende',
url: 'https://mastodon.social/tags/wintersonnenwende',
history: [
{ day: '1671580800', accounts: '162', uses: '176' },
{ day: '1671494400', accounts: '8', uses: '8' },
{ day: '1671408000', accounts: '0', uses: '0' },
{ day: '1671321600', accounts: '1', uses: '1' },
{ day: '1671235200', accounts: '2', uses: '2' },
{ day: '1671148800', accounts: '0', uses: '0' },
{ day: '1671062400', accounts: '1', uses: '1' },
],
},
{
name: 'solsticehedgehog',
url: 'https://mastodon.social/tags/solsticehedgehog',
history: [
{ day: '1671580800', accounts: '54', uses: '61' },
{ day: '1671494400', accounts: '0', uses: '0' },
{ day: '1671408000', accounts: '0', uses: '0' },
{ day: '1671321600', accounts: '0', uses: '0' },
{ day: '1671235200', accounts: '0', uses: '0' },
{ day: '1671148800', accounts: '0', uses: '0' },
{ day: '1671062400', accounts: '0', uses: '0' },
],
},
{
name: 'waterfallwednesday',
url: 'https://mastodon.social/tags/waterfallwednesday',
history: [
{ day: '1671580800', accounts: '109', uses: '116' },
{ day: '1671494400', accounts: '4', uses: '4' },
{ day: '1671408000', accounts: '3', uses: '3' },
{ day: '1671321600', accounts: '1', uses: '1' },
{ day: '1671235200', accounts: '2', uses: '2' },
{ day: '1671148800', accounts: '5', uses: '6' },
{ day: '1671062400', accounts: '77', uses: '82' },
],
},
{
name: 'bautzen',
url: 'https://mastodon.social/tags/bautzen',
history: [
{ day: '1671580800', accounts: '50', uses: '62' },
{ day: '1671494400', accounts: '1', uses: '2' },
{ day: '1671408000', accounts: '5', uses: '11' },
{ day: '1671321600', accounts: '1', uses: '1' },
{ day: '1671235200', accounts: '2', uses: '2' },
{ day: '1671148800', accounts: '7', uses: '8' },
{ day: '1671062400', accounts: '9', uses: '13' },
],
},
{
name: 'googlefonts',
url: 'https://mastodon.social/tags/googlefonts',
history: [
{ day: '1671580800', accounts: '47', uses: '47' },
{ day: '1671494400', accounts: '0', uses: '0' },
{ day: '1671408000', accounts: '1', uses: '1' },
{ day: '1671321600', accounts: '0', uses: '0' },
{ day: '1671235200', accounts: '0', uses: '0' },
{ day: '1671148800', accounts: '0', uses: '0' },
{ day: '1671062400', accounts: '1', uses: '1' },
],
},
]

Wyświetl plik

@ -14,6 +14,8 @@ import { getErrorHtml } from '~/utils/getErrorHtml/getErrorHtml'
import styles from '../../../../utils/innerHtmlContent.scss?inline'
import { getTextContent } from 'wildebeest/backend/src/activitypub/objects'
import { getDocumentHead } from '~/utils/getDocumentHead'
import { useAccountUrl } from '~/utils/useAccountUrl'
import { getDisplayNameElement } from '~/utils/getDisplayNameElement'
export const statusLoader = loader$<
{ DATABASE: D1Database },
@ -72,7 +74,7 @@ export default component$(() => {
})
export const AccountCard = component$<{ status: MastodonStatus }>(({ status }) => {
const accountUrl = `/@${status.account.username}`
const accountUrl = useAccountUrl(status.account)
return (
<div class="flex">
@ -80,7 +82,7 @@ export const AccountCard = component$<{ status: MastodonStatus }>(({ status }) =
<div class="flex flex-col">
<div class="p-1">
<Link href={accountUrl} class="no-underline">
{status.account.display_name}
{getDisplayNameElement(status.account)}
</Link>
</div>
<div class="p-1 text-wildebeest-400">@{status.account.acct}</div>

Wyświetl plik

@ -1,4 +1,4 @@
import { component$, useStyles$ } from '@builder.io/qwik'
import { $, component$, useStyles$ } from '@builder.io/qwik'
import { DocumentHead, loader$ } from '@builder.io/qwik-city'
import { MastodonAccount } from 'wildebeest/backend/src/types'
import StickyHeader from '~/components/StickyHeader/StickyHeader'
@ -9,18 +9,28 @@ import { getAccount } from 'wildebeest/backend/src/accounts/getAccount'
import { getNotFoundHtml } from '~/utils/getNotFoundHtml/getNotFoundHtml'
import { getErrorHtml } from '~/utils/getErrorHtml/getErrorHtml'
import { getDocumentHead } from '~/utils/getDocumentHead'
import type { Account, MastodonStatus } from '~/types'
import { StatusesPanel } from '~/components/StatusesPanel/StatusesPanel'
import { parseHandle } from 'wildebeest/backend/src/utils/parse'
import { getLocalStatuses } from 'wildebeest/functions/api/v1/accounts/[id]/statuses'
import { getDisplayNameElement } from '~/utils/getDisplayNameElement'
export const accountLoader = loader$<
{ DATABASE: D1Database },
Promise<{ account: MastodonAccount; accountHandle: string }>
Promise<{ account: MastodonAccount; accountHandle: string; statuses: MastodonStatus[] }>
>(async ({ platform, request, html }) => {
let account: MastodonAccount | null = null
let statuses: MastodonStatus[] = []
try {
const url = new URL(request.url)
const domain = url.hostname
const accountId = url.pathname.split('/')[1]
account = await getAccount(domain, accountId, platform.DATABASE)
const handle = parseHandle(accountId)
const response = await getLocalStatuses(request as Request, platform.DATABASE, handle)
statuses = await response.json<Array<MastodonStatus>>()
} catch {
throw html(
500,
@ -36,7 +46,7 @@ export const accountLoader = loader$<
const accountHandle = `@${account.acct}${accountDomain ? `@${accountDomain}` : ''}`
return { account, accountHandle }
return { account, accountHandle, statuses: JSON.parse(JSON.stringify(statuses)) }
})
export default component$(() => {
@ -70,38 +80,67 @@ export default component$(() => {
return (
<div>
<StickyHeader withBackButton />
<div class="relative mb-16">
<img
src={accountDetails.account.header}
alt={`Header of ${accountDetails.account.display_name}`}
class="w-full h-40 object-cover bg-wildebeest-500"
/>
<img
class="rounded h-24 w-24 absolute bottom-[-3rem] left-5 border-2 border-wildebeest-600"
src={accountDetails.account.avatar}
alt={`Avatar of ${accountDetails.account.display_name}`}
/>
</div>
<div class="px-5">
<h2 class="font-bold">{accountDetails.account.display_name}</h2>
<span class="block my-1 text-wildebeest-400">{accountDetails.accountHandle}</span>
<div class="inner-html-content my-5" dangerouslySetInnerHTML={accountDetails.account.note} />
<dl class="mb-6 flex flex-col bg-wildebeest-800 border border-wildebeest-600 rounded-md">
{fields.map(({ name, value }) => (
<div class="border-b border-wildebeest-600 p-3 text-sm" key={name}>
<dt class="uppercase font-semibold text-wildebeest-500 opacity-80 mb-1">{name}</dt>
<dd class="inner-html-content opacity-80 text-wildebeest-200" dangerouslySetInnerHTML={value}></dd>
</div>
))}
</dl>
<div data-testid="stats" class="pb-4 flex flex-wrap gap-5">
{stats.map(({ name, value }) => (
<div class="flex gap-1" key={name}>
<span class="font-semibold">{value}</span>
<span class="text-wildebeest-500">{name}</span>
</div>
))}
<div data-testid="account-info">
<div class="relative mb-16">
<img
src={accountDetails.account.header}
alt={`Header of ${accountDetails.account.display_name}`}
class="w-full h-40 object-cover bg-wildebeest-500"
/>
<img
class="rounded h-24 w-24 absolute bottom-[-3rem] left-5 border-2 border-wildebeest-600"
src={accountDetails.account.avatar}
alt={`Avatar of ${accountDetails.account.display_name}`}
/>
</div>
<div class="px-5">
<h2 class="font-bold">{getDisplayNameElement(accountDetails.account as Account)}</h2>
<span class="block my-1 text-wildebeest-400">{accountDetails.accountHandle}</span>
<div class="inner-html-content my-5" dangerouslySetInnerHTML={accountDetails.account.note} />
<dl class="mb-6 flex flex-col bg-wildebeest-800 border border-wildebeest-600 rounded-md">
{fields.map(({ name, value }) => (
<div class="border-b border-wildebeest-600 p-3 text-sm" key={name}>
<dt class="uppercase font-semibold text-wildebeest-500 opacity-80 mb-1">{name}</dt>
<dd class="inner-html-content opacity-80 text-wildebeest-200" dangerouslySetInnerHTML={value}></dd>
</div>
))}
</dl>
<div data-testid="stats" class="pb-4 flex flex-wrap gap-5">
{stats.map(({ name, value }) => (
<div class="flex gap-1" key={name}>
<span class="font-semibold">{value}</span>
<span class="text-wildebeest-500">{name}</span>
</div>
))}
</div>
</div>
<div class="bg-wildebeest-800 flex justify-around mt-6">
<span class="my-3 text-wildebeest-200">
<span>Posts</span>
</span>
</div>
</div>
<div data-testid="account-statuses">
{accountDetails.statuses.length > 0 && (
<StatusesPanel
initialStatuses={accountDetails.statuses}
fetchMoreStatuses={$(async (numOfCurrentStatuses: number) => {
let statuses: MastodonStatus[] = []
try {
const response = await fetch(
`/api/v1/accounts/${accountDetails.account.id}/statuses?offset=${numOfCurrentStatuses}`
)
if (response.ok) {
const results = await response.text()
statuses = JSON.parse(results)
}
} catch {
/* empty */
}
return statuses
})}
/>
)}
</div>
</div>
)

Wyświetl plik

@ -1,8 +1,8 @@
import { $, component$, useClientEffect$, useSignal } from '@builder.io/qwik'
import { $, component$ } from '@builder.io/qwik'
import { DocumentHead, loader$ } from '@builder.io/qwik-city'
import { RequestContext } from '@builder.io/qwik-city/middleware/request-handler'
import * as timelines from 'wildebeest/functions/api/v1/timelines/public'
import Status from '~/components/Status'
import { StatusesPanel } from '~/components/StatusesPanel/StatusesPanel'
import type { MastodonStatus } from '~/types'
import { getDocumentHead } from '~/utils/getDocumentHead'
import { getErrorHtml } from '~/utils/getErrorHtml/getErrorHtml'
@ -24,68 +24,24 @@ export const statusesLoader = loader$<{ DATABASE: D1Database; domain: string },
)
export default component$(() => {
const statuses = statusesLoader.use()
return <StatusesPanel initialStatuses={statuses.value} />
})
type StatusesPanelProps = { initialStatuses: MastodonStatus[] }
export const StatusesPanel = component$(({ initialStatuses }: StatusesPanelProps) => {
const fetchingMoreStatuses = useSignal(false)
const noMoreStatusesAvailable = useSignal(false)
const lastStatusRef = useSignal<HTMLDivElement>()
const statuses = useSignal<MastodonStatus[]>(initialStatuses)
const fetchMoreStatuses = $(async () => {
if (fetchingMoreStatuses.value || noMoreStatusesAvailable.value) {
return
}
fetchingMoreStatuses.value = true
const response = await fetch(`/api/v1/timelines/public?offset=${statuses.value.length}`)
fetchingMoreStatuses.value = false
if (response.ok) {
const results = await response.text()
const newStatuses: MastodonStatus[] = JSON.parse(results)
noMoreStatusesAvailable.value = newStatuses.length === 0
statuses.value = statuses.value.concat(newStatuses)
}
fetchingMoreStatuses.value = false
})
useClientEffect$(({ track }) => {
track(() => lastStatusRef.value)
if (lastStatusRef.value) {
const observer = new IntersectionObserver(
async ([lastStatus]) => {
if (lastStatus.isIntersecting) {
await fetchMoreStatuses()
observer.disconnect()
}
},
{ rootMargin: '250px' }
)
observer.observe(lastStatusRef.value)
}
})
const statuses = statusesLoader.use().value
return (
<>
{statuses.value.length > 0 ? (
statuses.value.map((status, i) => {
const isLastStatus = i === statuses.value.length - 1
const divProps = isLastStatus ? { ref: lastStatusRef } : {}
return (
<div key={status.id} {...divProps}>
<Status status={status} />
</div>
)
})
) : (
<div class="flex-1 grid place-items-center bg-wildebeest-600 text-center">
<p>Nothing to see right now. Check back later!</p>
</div>
)}
</>
<StatusesPanel
initialStatuses={statuses}
fetchMoreStatuses={$(async (numOfCurrentStatuses: number) => {
let statuses: MastodonStatus[] = []
try {
const response = await fetch(`/api/v1/timelines/public?offset=${numOfCurrentStatuses}`)
if (response.ok) {
const results = await response.text()
statuses = JSON.parse(results)
}
} catch {
/* empty */
}
return statuses
})}
/>
)
})

Wyświetl plik

@ -46,7 +46,7 @@ export default component$(() => {
<LeftColumn />
</div>
</div>
<div class="w-full xl:max-w-xl bg-wildebeest-600 xl:bg-transparent flex flex-col">
<div class="w-full xl:max-w-xl bg-wildebeest-600 xl:bg-transparent flex flex-col break-all sm:break-normal">
<div class="bg-wildebeest-600 rounded flex flex-1 flex-col">
<Slot />
</div>

Wyświetl plik

@ -1,11 +1,11 @@
import { component$ } from '@builder.io/qwik'
import { $, component$ } from '@builder.io/qwik'
import { MastodonStatus } from '~/types'
import * as timelines from 'wildebeest/functions/api/v1/timelines/public'
import Status from '~/components/Status'
import { DocumentHead, loader$ } from '@builder.io/qwik-city'
import StickyHeader from '~/components/StickyHeader/StickyHeader'
import { getDocumentHead } from '~/utils/getDocumentHead'
import { RequestContext } from '@builder.io/qwik-city/middleware/request-handler'
import { StatusesPanel } from '~/components/StatusesPanel/StatusesPanel'
export const statusesLoader = loader$<{ DATABASE: D1Database; domain: string }, Promise<MastodonStatus[]>>(
async ({ platform, html }) => {
@ -22,7 +22,7 @@ export const statusesLoader = loader$<{ DATABASE: D1Database; domain: string },
)
export default component$(() => {
const statuses = statusesLoader.use()
const statuses = statusesLoader.use().value
return (
<>
@ -32,13 +32,22 @@ export default component$(() => {
<span>Federated timeline</span>
</div>
</StickyHeader>
{statuses.value.length > 0 ? (
statuses.value.map((status) => <Status status={status} />)
) : (
<div class="flex-1 grid place-items-center bg-wildebeest-600 text-center">
<p>Nothing to see right now. Check back later!</p>
</div>
)}
<StatusesPanel
initialStatuses={statuses}
fetchMoreStatuses={$(async (numOfCurrentStatuses: number) => {
let statuses: MastodonStatus[] = []
try {
const response = await fetch(`/api/v1/timelines/public?offset=${numOfCurrentStatuses}`)
if (response.ok) {
const results = await response.text()
statuses = JSON.parse(results)
}
} catch {
/* empty */
}
return statuses
})}
/>
</>
)
})

Wyświetl plik

@ -1,11 +1,11 @@
import { component$ } from '@builder.io/qwik'
import { $, component$ } from '@builder.io/qwik'
import { MastodonStatus } from '~/types'
import * as timelines from 'wildebeest/functions/api/v1/timelines/public'
import Status from '~/components/Status'
import { DocumentHead, loader$ } from '@builder.io/qwik-city'
import StickyHeader from '~/components/StickyHeader/StickyHeader'
import { getDocumentHead } from '~/utils/getDocumentHead'
import { RequestContext } from '@builder.io/qwik-city/middleware/request-handler'
import { StatusesPanel } from '~/components/StatusesPanel/StatusesPanel'
export const statusesLoader = loader$<{ DATABASE: D1Database; domain: string }, Promise<MastodonStatus[]>>(
async ({ platform, html }) => {
@ -22,7 +22,7 @@ export const statusesLoader = loader$<{ DATABASE: D1Database; domain: string },
)
export default component$(() => {
const statuses = statusesLoader.use()
const statuses = statusesLoader.use().value
return (
<>
<StickyHeader>
@ -31,13 +31,22 @@ export default component$(() => {
<span>Local timeline</span>
</div>
</StickyHeader>
{statuses.value.length > 0 ? (
statuses.value.map((status) => <Status status={status} />)
) : (
<div class="flex-1 grid place-items-center bg-wildebeest-600 text-center">
<p>Nothing to see right now. Check back later!</p>
</div>
)}
<StatusesPanel
initialStatuses={statuses}
fetchMoreStatuses={$(async (numOfCurrentStatuses: number) => {
let statuses: MastodonStatus[] = []
try {
const response = await fetch(`/api/v1/timelines/public?local=true&offset=${numOfCurrentStatuses}`)
if (response.ok) {
const results = await response.text()
statuses = JSON.parse(results)
}
} catch {
/* empty */
}
return statuses
})}
/>
</>
)
})

Wyświetl plik

@ -20,6 +20,32 @@
--wildebeest-vibrant-color-600: hsl(var(--wildebeest-vibrant-hue), 100%, 67%);
}
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
::-webkit-scrollbar-corner {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--wildebeest-color-700);
border: 0;
border-radius: 0;
}
::-webkit-scrollbar-track {
border: 0;
border-radius: 0;
background: var(--wildebeest-color-900);
}
::-webkit-scrollbar-track:hover,
::-webkit-scrollbar-track:active {
background: var(--wildebeest-color-600);
}
body {
font-family: ui-sans-serif, sans-serif, ui-sans-serif;
line-height: 1.25rem;
@ -39,3 +65,14 @@ button {
.pointer {
cursor: pointer;
}
.custom-emoji {
display: inline-block;
font-size: inherit;
vertical-align: middle;
-o-object-fit: contain;
object-fit: contain;
margin: -.2ex 0.15em .2ex;
width: 1rem;
height: 1rem;
}

Wyświetl plik

@ -0,0 +1,21 @@
import { type JSXNode } from '@builder.io/qwik'
import { type Account } from '~/types'
export function getDisplayNameElement(account: Account): JSXNode {
return (
<>
{account.display_name.split(/(:[^\s:]+:)/g).map((str) => {
const customEmojiMatch = str.match(/^:([^\s:]+):$/)
if (customEmojiMatch) {
const shortCode = customEmojiMatch[1]
const customEmojiInfo = account.emojis.find((emoji) => emoji.shortcode === shortCode)
if (customEmojiInfo) {
// eslint-disable-next-line qwik/single-jsx-root
return <img class="custom-emoji" src={customEmojiInfo.url} alt={`:${shortCode}:`}></img>
}
}
return <>{str}</>
})}
</>
)
}

Wyświetl plik

@ -7,6 +7,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Wildebeest Error Page">
<title>Wildebeest Error</title>
<meta name="og:title" content="Wildebeest Error">
<meta name="og:description" content="Wildebeest Error Page">
<meta name="og:type" content="website">
<style>
body {
margin: 0;

Wyświetl plik

@ -7,6 +7,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Wildebeest Page Not Found">
<title>Wildebeest Not Found</title>
<meta name="og:title" content="Wildebeest Not Found">
<meta name="og:description" content="Wildebeest Page Not Found">
<meta name="og:type" content="website">
<style>
body {
margin: 0;

Wyświetl plik

@ -31,10 +31,15 @@
white-space: nowrap;
overflow: hidden;
text-decoration: none;
display: inline-block;
vertical-align: middle;
max-width: 50%;
}
.ellipsis::after {
content: "...";
@screen sm {
.ellipsis {
display: inline;
}
}
.status-link {

Wyświetl plik

@ -0,0 +1,46 @@
import { useSignal, useTask$ } from '@builder.io/qwik'
import { parseHandle } from 'wildebeest/backend/src/utils/parse'
import { Account } from '~/types'
import { useDomain } from './useDomain'
/**
* Hook to get a url to use for links for the provided account.
*
* Note: using account.url is not sufficient since we want to distinguish
* between local and remote accounts and change the url accordingly
*
* @param account the target account or null
* @returns url to be used for the target account (or undefined if)
*/
export function useAccountUrl(
account: (Partial<Pick<Account, 'id'>> & Pick<Account, 'url'>) | null
): string | undefined {
if (!account?.id) {
return account?.url
}
const isLocal = useAccountIsLocal(account?.id)
if (account && isLocal.value) {
const url = new URL(account.url)
return url.pathname
}
return account?.url
}
function useAccountIsLocal(accountId: string | undefined) {
const domain = useDomain()
const isLocal = useSignal(false)
useTask$(({ track }) => {
track(() => accountId)
if (accountId) {
const handle = parseHandle(accountId)
isLocal.value = handle.domain === null || handle.domain === domain
}
})
return isLocal
}

Wyświetl plik

@ -1,8 +1,10 @@
import { useLocation } from '@builder.io/qwik-city'
import { adjustLocalHostDomain } from 'wildebeest/backend/src/utils/adjustLocalHostDomain'
export const useDomain = () => {
const location = useLocation()
const url = new URL(location.href)
const domain = url.hostname
return domain
const adjustedDomain = adjustLocalHostDomain(domain)
return adjustedDomain
}

Wyświetl plik

@ -1,6 +1,8 @@
import type { Env } from 'wildebeest/backend/src/types/env'
import { cors } from 'wildebeest/backend/src/utils/cors'
const headers = {
...cors(),
'content-type': 'application/json',
'cache-control': 'max-age=259200, public',
}

Wyświetl plik

@ -35,6 +35,10 @@ export async function handleRequest(domain: string, db: D1Database, id: string):
{
toot: 'http://joinmastodon.org/ns#',
discoverable: 'toot:discoverable',
alsoKnownAs: {
'@id': 'as:alsoKnownAs',
'@type': '@id',
},
},
],

Wyświetl plik

@ -17,6 +17,7 @@ import * as webfinger from 'wildebeest/backend/src/webfinger'
import * as outbox from 'wildebeest/backend/src/activitypub/actors/outbox'
import * as actors from 'wildebeest/backend/src/activitypub/actors'
import { toMastodonStatusFromRow } from 'wildebeest/backend/src/mastodon/status'
import { adjustLocalHostDomain } from 'wildebeest/backend/src/utils/adjustLocalHostDomain'
const headers = {
...cors(),
@ -29,11 +30,13 @@ export const onRequest: PagesFunction<Env, any, ContextData> = async ({ request,
export async function handleRequest(request: Request, db: D1Database, id: string): Promise<Response> {
const handle = parseHandle(id)
const domain = new URL(request.url).hostname
const url = new URL(request.url)
const domain = url.hostname
const offset = Number.parseInt(url.searchParams.get('offset') ?? '0')
if (handle.domain === null || (handle.domain !== null && handle.domain === domain)) {
// Retrieve the statuses from a local user
return getLocalStatuses(request, db, handle)
return getLocalStatuses(request, db, handle, offset)
} else if (handle.domain !== null) {
// Retrieve the statuses of a remote actor
return getRemoteStatuses(request, handle, db)
@ -112,9 +115,14 @@ async function getRemoteStatuses(request: Request, handle: Handle, db: D1Databas
return new Response(JSON.stringify(statuses), { headers })
}
async function getLocalStatuses(request: Request, db: D1Database, handle: Handle): Promise<Response> {
export async function getLocalStatuses(
request: Request,
db: D1Database,
handle: Handle,
offset = 0
): Promise<Response> {
const domain = new URL(request.url).hostname
const actorId = actorURL(domain, handle.localPart)
const actorId = actorURL(adjustLocalHostDomain(domain), handle.localPart)
const QUERY = `
SELECT objects.*,
@ -134,7 +142,7 @@ WHERE objects.type='Note'
AND outbox_objects.actor_id = ?1
AND outbox_objects.cdate > ?2
ORDER by outbox_objects.published_date DESC
LIMIT ?3
LIMIT ?3 OFFSET ?4
`
const DEFAULT_LIMIT = 20
@ -164,7 +172,10 @@ LIMIT ?3
afterCdate = row.cdate
}
const { success, error, results } = await db.prepare(QUERY).bind(actorId.toString(), afterCdate, DEFAULT_LIMIT).all()
const { success, error, results } = await db
.prepare(QUERY)
.bind(actorId.toString(), afterCdate, DEFAULT_LIMIT, offset)
.all()
if (!success) {
throw new Error('SQL error: ' + error)
}

Wyświetl plik

@ -37,6 +37,9 @@ export async function handleRequest(db: D1Database, request: Request, vapidKeys:
client_secret: client.secret,
vapid_key: vapidKey,
// FIXME: stub value
id: '20',
}
const headers = {
...cors(),

Wyświetl plik

@ -11,7 +11,7 @@ import * as errors from 'wildebeest/backend/src/errors'
import { VAPIDPublicKey } from 'wildebeest/backend/src/mastodon/subscription'
export const onRequestGet: PagesFunction<Env, any, ContextData> = async ({ request, env, data }) => {
return handleGetRequest(env.DATABASE, request, data.connectedActor, data.clientId)
return handleGetRequest(env.DATABASE, request, data.connectedActor, data.clientId, getVAPIDKeys(env))
}
export const onRequestPost: PagesFunction<Env, any, ContextData> = async ({ request, env, data }) => {
@ -23,7 +23,13 @@ const headers = {
'content-type': 'application/json; charset=utf-8',
}
export async function handleGetRequest(db: D1Database, request: Request, connectedActor: Actor, clientId: string) {
export async function handleGetRequest(
db: D1Database,
request: Request,
connectedActor: Actor,
clientId: string,
vapidKeys: JWK
) {
const client = await getClientById(db, clientId)
if (client === null) {
return errors.clientUnknown()
@ -32,23 +38,17 @@ export async function handleGetRequest(db: D1Database, request: Request, connect
const subscription = await getSubscription(db, connectedActor, client)
if (subscription === null) {
return new Response('', { status: 404 })
return errors.resourceNotFound('subscription', clientId)
}
const res = {
id: 4,
endpoint: subscription.gateway.endpoint,
alerts: {
follow: true,
favourite: true,
reblog: true,
mention: true,
poll: true,
},
policy: 'all',
const vapidKey = VAPIDPublicKey(vapidKeys)
// FIXME: stub value
server_key: 'TODO',
const res = {
id: subscription.id,
endpoint: subscription.gateway.endpoint,
alerts: subscription.alerts,
policy: subscription.policy,
server_key: vapidKey,
}
return new Response(JSON.stringify(res), { headers })
@ -77,16 +77,10 @@ export async function handlePostRequest(
const vapidKey = VAPIDPublicKey(vapidKeys)
const res = {
id: 4,
endpoint: data.subscription.endpoint,
alerts: {
follow: true,
favourite: true,
reblog: true,
mention: true,
poll: true,
},
policy: 'all',
id: subscription.id,
endpoint: subscription.gateway.endpoint,
alerts: subscription.alerts,
policy: subscription.policy,
server_key: vapidKey,
}

Wyświetl plik

@ -9,6 +9,7 @@ import type { Queue, DeliverMessageBody } from 'wildebeest/backend/src/types/que
import type { Document } from 'wildebeest/backend/src/activitypub/objects'
import { getObjectByMastodonId } from 'wildebeest/backend/src/activitypub/objects'
import { createStatus, getMentions } from 'wildebeest/backend/src/mastodon/status'
import { getHashtags, insertHashtags } from 'wildebeest/backend/src/mastodon/hashtag'
import * as activities from 'wildebeest/backend/src/activitypub/activities/create'
import type { Env } from 'wildebeest/backend/src/types/env'
import type { ContextData } from 'wildebeest/backend/src/types/context'
@ -104,6 +105,8 @@ export async function handleRequest(
extraProperties.inReplyTo = inReplyToObject[originalObjectIdSymbol] || inReplyToObject.id.toString()
}
const hashtags = getHashtags(body.status)
const content = enrichStatus(body.status)
const mentions = await getMentions(body.status, domain)
if (mentions.length > 0) {
@ -112,6 +115,10 @@ export async function handleRequest(
const note = await createStatus(domain, db, connectedActor, content, mediaAttachments, extraProperties)
if (hashtags.length > 0) {
await insertHashtags(db, note, hashtags)
}
if (inReplyToObject !== null) {
// after the status has been created, record the reply.
await insertReply(db, connectedActor, note, inReplyToObject)

Wyświetl plik

@ -0,0 +1,25 @@
// https://docs.joinmastodon.org/methods/tags/#get
import type { ContextData } from 'wildebeest/backend/src/types/context'
import type { Env } from 'wildebeest/backend/src/types/env'
import { getTag } from 'wildebeest/backend/src/mastodon/hashtag'
import * as errors from 'wildebeest/backend/src/errors'
import { cors } from 'wildebeest/backend/src/utils/cors'
const headers = {
...cors(),
'content-type': 'application/json',
} as const
export const onRequestGet: PagesFunction<Env, any, ContextData> = async ({ params, env, request }) => {
const domain = new URL(request.url).hostname
return handleRequestGet(env.DATABASE, domain, params.tag as string)
}
export async function handleRequestGet(db: D1Database, domain: string, value: string): Promise<Response> {
const tag = await getTag(db, domain, value)
if (tag === null) {
return errors.tagNotFound(value)
}
return new Response(JSON.stringify(tag), { headers })
}

Wyświetl plik

@ -0,0 +1,24 @@
import type { Env } from 'wildebeest/backend/src/types/env'
import { cors } from 'wildebeest/backend/src/utils/cors'
import type { ContextData } from 'wildebeest/backend/src/types/context'
import * as timelines from 'wildebeest/backend/src/mastodon/timeline'
const headers = {
...cors(),
'content-type': 'application/json; charset=utf-8',
}
export const onRequest: PagesFunction<Env, any, ContextData> = async ({ request, env, params }) => {
const domain = new URL(request.url).hostname
return handleRequest(env.DATABASE, request, domain, params.tag as string)
}
export async function handleRequest(db: D1Database, request: Request, domain: string, tag: string): Promise<Response> {
const url = new URL(request.url)
if (url.searchParams.has('max_id')) {
return new Response(JSON.stringify([]), { headers })
}
const timeline = await timelines.getPublicTimeline(domain, db, timelines.LocalPreference.NotSet, 0, tag)
return new Response(JSON.stringify(timeline), { headers })
}

Wyświetl plik

@ -7,6 +7,7 @@ import { MastodonAccount } from 'wildebeest/backend/src/types/account'
import { parseHandle } from 'wildebeest/backend/src/utils/parse'
import { loadExternalMastodonAccount } from 'wildebeest/backend/src/mastodon/account'
import { personFromRow } from 'wildebeest/backend/src/activitypub/actors'
import type { Handle } from 'wildebeest/backend/src/utils/parse'
const headers = {
...cors(),
@ -38,7 +39,14 @@ export async function handleRequest(db: D1Database, request: Request): Promise<R
hashtags: [],
}
const query = parseHandle(url.searchParams.get('q') || '')
let query: Handle
try {
query = parseHandle(url.searchParams.get('q') || '')
} catch (err: any) {
return new Response('', { status: 400 })
}
if (useWebFinger && query.domain !== null) {
const acct = `${query.localPart}@${query.domain}`
const res = await queryAcct(query.domain, acct)

Wyświetl plik

@ -0,0 +1,34 @@
import type { Env } from 'wildebeest/backend/src/types/env'
import { setActorAlias } from 'wildebeest/backend/src/activitypub/actors'
import type { ContextData } from 'wildebeest/backend/src/types/context'
import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
import { parseHandle } from 'wildebeest/backend/src/utils/parse'
import { queryAcct } from 'wildebeest/backend/src/webfinger'
import * as errors from 'wildebeest/backend/src/errors'
export const onRequestPost: PagesFunction<Env, any, ContextData> = async ({ env, request, data }) => {
return handleRequestPost(env.DATABASE, request, data.connectedActor)
}
type AddAliasRequest = {
alias: string
}
export async function handleRequestPost(db: D1Database, request: Request, connectedActor: Actor): Promise<Response> {
const body = await request.json<AddAliasRequest>()
const handle = parseHandle(body.alias)
const acct = `${handle.localPart}@${handle.domain}`
if (handle.domain === null) {
console.warn("account migration within an instance isn't supported")
return new Response('', { status: 400 })
}
const actor = await queryAcct(handle.domain, acct)
if (actor === null) {
return errors.resourceNotFound('actor', acct)
}
await setActorAlias(db, connectedActor.id, actor.id)
return new Response('', { status: 201 })
}

Wyświetl plik

@ -1,7 +1,9 @@
import type { Env } from 'wildebeest/backend/src/types/env'
import { WILDEBEEST_VERSION } from 'wildebeest/config/versions'
import { cors } from 'wildebeest/backend/src/utils/cors'
const headers = {
...cors(),
'content-type': 'application/json',
'cache-control': 'max-age=259200, public',
}

Wyświetl plik

@ -1,7 +1,9 @@
import type { Env } from 'wildebeest/backend/src/types/env'
import { cors } from 'wildebeest/backend/src/utils/cors'
import { WILDEBEEST_VERSION } from 'wildebeest/config/versions'
const headers = {
...cors(),
'content-type': 'application/json',
'cache-control': 'max-age=259200, public',
}

Wyświetl plik

@ -24,12 +24,23 @@ export async function handleRequest(db: D1Database, request: Request): Promise<R
return new Response('', { headers })
}
const data = await readBody<Body>(request)
if (!data.code) {
let data: Body = { code: null }
try {
data = await readBody<Body>(request)
} catch (err: any) {
// ignore error
}
let code = data.code
if (!code) {
const url = new URL(request.url)
code = url.searchParams.get('code')
}
if (!code) {
return errors.notAuthorized('missing authorization')
}
const parts = data.code.split('.')
const parts = code.split('.')
const clientId = parts[0]
const client = await getClientById(db, clientId)
@ -38,7 +49,7 @@ export async function handleRequest(db: D1Database, request: Request): Promise<R
}
const res = {
access_token: data.code,
access_token: code,
token_type: 'Bearer',
scope: client.scopes,
created_at: (Date.now() / 1000) | 0,

Wyświetl plik

@ -0,0 +1,9 @@
-- Migration number: 0006 2023-02-13T11:18:03.485Z
CREATE TABLE IF NOT EXISTS note_hashtags (
value TEXT NOT NULL,
object_id TEXT NOT NULL,
cdate DATETIME NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
FOREIGN KEY(object_id) REFERENCES objects(id)
);

Wyświetl plik

@ -0,0 +1,28 @@
-- Migration number: 0007 2023-02-15T11:01:46.585Z
DROP table subscriptions;
CREATE TABLE subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_id TEXT NOT NULL,
client_id TEXT NOT NULL,
endpoint TEXT NULL NOT NULL,
key_p256dh TEXT NOT NULL,
key_auth TEXT NOT NULL,
alert_mention INTEGER NOT NULL,
alert_status INTEGER NOT NULL,
alert_reblog INTEGER NOT NULL,
alert_follow INTEGER NOT NULL,
alert_follow_request INTEGER NOT NULL,
alert_favourite INTEGER NOT NULL,
alert_poll INTEGER NOT NULL,
alert_update INTEGER NOT NULL,
alert_admin_sign_up INTEGER NOT NULL,
alert_admin_report INTEGER NOT NULL,
policy TEXT NOT NULL,
cdate DATETIME NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
UNIQUE(actor_id, client_id)
FOREIGN KEY(actor_id) REFERENCES actors(id),
FOREIGN KEY(client_id) REFERENCES clients(id)
);

Wyświetl plik

@ -13,20 +13,20 @@ import { devices } from '@playwright/test'
const config: PlaywrightTestConfig = {
testDir: './ui-e2e-tests',
/* Maximum time one test can run for. */
timeout: 40 * 1000,
timeout: 30 * 1000,
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 7000,
timeout: 5000,
},
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 5 : 0,
retries: process.env.CI ? 3 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */

Wyświetl plik

@ -1,14 +1,52 @@
import { test, expect } from '@playwright/test'
test('Navigation to and view of an account', async ({ page }) => {
const navigationsVia = ['name link', 'avatar'] as const
navigationsVia.forEach((via) =>
test(`Navigation via ${via} to and view of an account (with 1 post)`, async ({ page }) => {
await page.goto('http://127.0.0.1:8788/explore')
await page.getByRole('article').getByRole('link', { name: 'Ben, just Ben', exact: true }).click()
await page.getByRole('link', { name: 'Ben, just Ben', exact: true }).click()
await page.waitForLoadState('networkidle')
const linkOption =
via === 'name link' ? { name: 'Ben, just Ben', exact: true } : { name: 'Avatar of Ben, just Ben' }
await page.getByRole('link', linkOption).click()
await expect(page.getByTestId('account-info').getByRole('img', { name: 'Header of Ben, just Ben' })).toBeVisible()
await expect(page.getByTestId('account-info').getByRole('img', { name: 'Avatar of Ben, just Ben' })).toBeVisible()
await expect(page.getByTestId('account-info').getByRole('heading', { name: 'Ben, just Ben' })).toBeVisible()
await expect(page.getByTestId('account-info').getByText('Joined')).toBeVisible()
await expect(page.getByTestId('account-info').getByTestId('stats')).toHaveText('1Posts0Following0Followers')
expect(await page.getByTestId('account-statuses').getByRole('article').count()).toBe(1)
await expect(
page.getByTestId('account-statuses').getByRole('article').getByRole('img', { name: 'Avatar of Ben, just Ben' })
).toBeVisible()
await expect(page.getByTestId('account-statuses').getByRole('article')).toContainText(
'A very simple update: all good!'
)
})
)
test('Navigation to and view of an account (with 2 posts)', async ({ page }) => {
await page.goto('http://127.0.0.1:8788/explore')
await page.getByRole('article').getByRole('link', { name: 'Ben Rosengart', exact: true }).click()
await page.getByRole('link', { name: 'Ben Rosengart', exact: true }).click()
await page
.locator('article')
.filter({ hasText: "I'm Rafael and I am a web designer" })
.locator('i.fa-globe + span')
.click()
await page.waitForLoadState('networkidle')
await page.getByRole('link', { name: 'Ben Rosengart', exact: true }).click()
await expect(page.getByRole('img', { name: 'Header of Ben Rosengart' })).toBeVisible()
await expect(page.getByRole('img', { name: 'Avatar of Ben Rosengart' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Ben Rosengart' })).toBeVisible()
await expect(page.getByText('Joined')).toBeVisible()
await expect(page.getByTestId('stats')).toHaveText('1Posts0Following0Followers')
await page.getByRole('link', { name: 'Raffa123$', exact: true }).click()
await expect(page.getByTestId('account-info').getByRole('img', { name: 'Header of Raffa123$' })).toBeVisible()
await expect(page.getByTestId('account-info').getByRole('img', { name: 'Avatar of Raffa123$' })).toBeVisible()
await expect(page.getByTestId('account-info').getByRole('heading', { name: 'Raffa123$' })).toBeVisible()
await expect(page.getByTestId('account-info').getByText('Joined')).toBeVisible()
await expect(page.getByTestId('account-info').getByTestId('stats')).toHaveText('2Posts0Following0Followers')
expect(await page.getByTestId('account-statuses').getByRole('article').count()).toBe(2)
const [post1Locator, post2Locator] = await page.getByTestId('account-statuses').getByRole('article').all()
await expect(post1Locator.getByRole('img', { name: 'Avatar of Raffa123$' })).toBeVisible()
await expect(post1Locator).toContainText("I'm Rafael and I am a web designer")
await expect(post2Locator.getByRole('img', { name: 'Avatar of Raffa123$' })).toBeVisible()
await expect(post2Locator).toContainText('Hi! My name is Rafael!')
})

Wyświetl plik

@ -0,0 +1,49 @@
import { test, expect } from '@playwright/test'
test('View of custom emojis in an toots author display name', async ({ page, browserName }) => {
// this page.route is a hack to mock the custom emojis since they haven't
// yet been implemented in the backend (this should be not needed and removed
// when those are implemented)
test.skip(
browserName !== 'chromium',
"Only chromium seem to test this well, I suspect it's because of the page.route"
)
await page.route('http://127.0.0.1:8788/@george/*/q-data.json', async (route) => {
const response = await route.fetch()
let body = await response.text()
body = body.replace(
/"emojis":\[\]/g,
`"emojis": ${JSON.stringify([
{
shortcode: 'verified',
url: 'https://files.mastodon.social/cache/custom_emojis/images/000/452/462/original/947cae7ac4dfdfa0.png',
static_url:
'https://files.mastodon.social/cache/custom_emojis/images/000/452/462/static/947cae7ac4dfdfa0.png',
visible_in_picker: true,
},
])}`
)
await route.fulfill({
response,
body,
})
})
await page.goto('http://127.0.0.1:8788/explore')
await page
.locator('article')
.filter({ hasText: 'George' })
.filter({
hasText: 'We did it!',
})
.locator('i.fa-globe + span')
.click()
const customEmojiLocator = page.getByRole('link', { name: 'George :verified: 👍', exact: true }).getByRole('img')
await expect(customEmojiLocator).toBeVisible()
await expect(customEmojiLocator).toHaveAttribute(
'src',
'https://files.mastodon.social/cache/custom_emojis/images/000/452/462/original/947cae7ac4dfdfa0.png'
)
})

Wyświetl plik

@ -4,10 +4,10 @@ test('Display the list of toots in the explore page', async ({ page }) => {
await page.goto('http://127.0.0.1:8788/explore')
const tootsTextsToCheck = [
'Hi, meet HiDock',
'George Santos is in serious trouble.',
'The real message of Jurassic Park is that you get the Unix and IT support you pay for.',
'BREAKING: Black smoke coming from Capitol chimney.',
'Hi! My name is Rafael!',
'We did it!',
"Fine. I'll use Wildebeest",
'A very simple update: all good!',
]
for (const tootText of tootsTextsToCheck) {

Wyświetl plik

@ -0,0 +1,93 @@
import { test, expect, Page, Request } from '@playwright/test'
import type { Account, MastodonStatus } from 'wildebeest/frontend/src/types'
test.describe('Infinite (statuses) scrolling', () => {
const tests = [
{
description: 'in explore page',
goToPageFn: async (page: Page) => await page.goto('http://127.0.0.1:8788/explore'),
fetchUrl: 'http://127.0.0.1:8788/api/v1/timelines/public?*',
},
{
description: 'in local page',
goToPageFn: async (page: Page) => await page.goto('http://127.0.0.1:8788/public/local'),
fetchUrl: 'http://127.0.0.1:8788/api/v1/timelines/public?*',
isRequestValid: (request: Request) => {
const searchParams = new URL(request.url()).searchParams
return searchParams.get('local') === 'true'
},
},
{
description: 'in federated page',
goToPageFn: async (page: Page) => await page.goto('http://127.0.0.1:8788/public'),
fetchUrl: 'http://127.0.0.1:8788/api/v1/timelines/public?*',
},
{
description: 'in account page',
goToPageFn: async (page: Page) => {
await page.goto('http://127.0.0.1:8788/explore')
await page.locator('article').filter({ hasText: "I'm Rafael" }).locator('i.fa-globe + span').click()
await page.waitForLoadState('networkidle')
await page.getByRole('link', { name: 'Raffa123$', exact: true }).click()
await expect(page.getByTestId('account-info').getByRole('img', { name: 'Header of Raffa123$' })).toBeVisible()
},
fetchUrl: 'http://127.0.0.1:8788/api/v1/accounts/Rafael/statuses?*',
},
]
tests.forEach(({ description, fetchUrl, goToPageFn, isRequestValid }) =>
test(description, async ({ page, browserName }) => {
test.skip(browserName !== 'chromium', 'Only chromium tests infinite scrolling well')
await goToPageFn(page)
await page.waitForLoadState('networkidle')
const generateFakeStatus = getMockStatusFn()
await page.route(fetchUrl, async (route, request) => {
let newStatuses: MastodonStatus[] = []
if (!isRequestValid || isRequestValid(request)) {
newStatuses = new Array(5).fill(null).map(generateFakeStatus)
}
await route.fulfill({ body: JSON.stringify(newStatuses) })
})
for (let i = 0; i < 3; i++) {
await page.keyboard.down('End')
for (let j = 0; j < 5; j++) {
const paddedJ = `${i * 5 + j}`.padStart(3, '0')
// check that the new toots have been loaded
await expect(page.locator('article').filter({ hasText: `Mock Fetched Status #${paddedJ}` })).toBeVisible()
}
const paddedExtra = `${i * 5 + 6}`.padStart(3, '0')
// check that a 6th toot hasn't been loaded (since the mock endpoint returns 5 toots)
await expect(
page.locator('article').filter({ hasText: `Mock Fetched Status #${paddedExtra}` })
).not.toBeVisible()
}
const numOfMockFetchedToots = await page.locator('article').filter({ hasText: `Mock Fetched Status` }).count()
// check that all 15 toots have been loaded
expect(numOfMockFetchedToots).toBe(15)
})
)
})
/**
* generates a function that creates mock statuses when called,
* it uses a closure to keep track of the number of generated
* statuses to that they are consistently enumerated
* ('Mock Fetched Status #000', 'Mock Fetched Status #001', ...)
*/
export function getMockStatusFn(): () => MastodonStatus {
let numOfGeneratedMockStatuses = 0
return () => {
const paddedNum = `${numOfGeneratedMockStatuses}`.padStart(3, '0')
const status = {
content: `Mock Fetched Status #${paddedNum}`,
account: { display_name: 'test', emojis: [] } as unknown as Account,
media_attachments: [],
} as unknown as MastodonStatus
numOfGeneratedMockStatuses++
return status
}
}

Wyświetl plik

@ -2,21 +2,37 @@ import { test, expect } from '@playwright/test'
test('Navigation to and view of an individual toot', async ({ page }) => {
await page.goto('http://127.0.0.1:8788/explore')
await page.locator('article').filter({ hasText: 'Ken White' }).locator('i.fa-globe + span').click()
await page.locator('article').filter({ hasText: 'Ben, just Ben' }).locator('i.fa-globe + span').click()
await expect(page.getByRole('button', { name: 'Back' })).toBeVisible()
await expect(page.getByRole('link', { name: 'Avatar of Ben, just Ben' })).toBeVisible()
await expect(page.getByRole('link', { name: 'Ben, just Ben', exact: true })).toBeVisible()
await expect(page.locator('span', { hasText: 'A very simple update: all good!' })).toBeVisible()
})
const backButtonLocator = page.getByRole('button', { name: 'Back' })
await expect(backButtonLocator).toBeVisible()
const avatarLocator = page.locator('img[alt="Avatar of Ken White"]')
await expect(avatarLocator).toBeVisible()
const userLinkLocator = page.locator('a[href="/@Popehat"]', { hasText: 'Ken White' })
await expect(userLinkLocator).toBeVisible()
const tootContentLocator = page.locator('p', {
hasText: 'Just recorded the first Serious Trouble episode of the new year, out tomorrow.',
})
await expect(tootContentLocator).toBeVisible()
test('Navigation to and view of an individual toot with images', async ({ page }) => {
await page.goto('http://127.0.0.1:8788/explore')
await page
.locator('article')
.filter({ hasText: "I'm Rafael and I am a web designer!" })
.locator('i.fa-globe + span')
.click()
await expect(page.getByRole('button', { name: 'Back' })).toBeVisible()
await expect(page.getByRole('link', { name: 'Avatar of Raffa123$' })).toBeVisible()
await expect(page.getByRole('link', { name: 'Raffa123$', exact: true })).toBeVisible()
await expect(page.locator('p', { hasText: "I'm Rafael and I am a web designer!" })).toBeVisible()
expect(await page.getByTestId('media-gallery').getByRole('img').count()).toBe(4)
await expect(page.getByTestId('images-modal')).not.toBeVisible()
await page.getByTestId('media-gallery').getByRole('img').nth(2).click()
await expect(page.getByTestId('images-modal')).toBeVisible()
for (const n of [2, 1, 0, 3, 2, 1, 0, 3]) {
await expect(page.getByTestId('images-modal').getByRole('img')).toHaveAttribute(
'src',
`https://loremflickr.com/640/480/abstract?lock=${100 + n}`
)
await page.getByTestId('left-btn').click()
}
await page.getByTestId('close-btn').click()
await expect(page.getByTestId('images-modal')).not.toBeVisible()
})
test("Navigation to and view of a toot's replies", async ({ page }) => {
@ -24,38 +40,38 @@ test("Navigation to and view of a toot's replies", async ({ page }) => {
await page
.locator('article')
.filter({ hasText: 'Bethany Black' })
.filter({ hasText: 'George' })
.filter({
hasText: 'We did it! *wipes tear from eye*',
hasText: 'We did it!',
})
.locator('i.fa-globe + span')
.click()
await page
.locator('article')
.filter({ hasText: 'Zach Weinersmith' })
.filter({ hasText: 'Zak Smith' })
.filter({
hasText: 'Yes we did!',
})
.locator('i.fa-globe + span')
.click()
await expect(page.getByRole('link', { name: 'Avatar of Zach Weinersmith' })).toBeVisible()
await expect(page.getByRole('link', { name: 'Zach Weinersmith', exact: true })).toBeVisible()
await expect(page.getByRole('link', { name: 'Avatar of Zak Smith' })).toBeVisible()
await expect(page.getByRole('link', { name: 'Zak Smith', exact: true })).toBeVisible()
await expect(page.getByText('Yes we did!')).toBeVisible()
await page.getByRole('button', { name: 'Back' }).click()
await page
.locator('article')
.filter({ hasText: 'nixCraft' })
.filter({ hasText: 'Penny' })
.filter({
hasText: 'Yes you guys did it!',
})
.locator('i.fa-globe + span')
.click()
await expect(page.getByRole('link', { name: 'Avatar of nixCraft' })).toBeVisible()
await expect(page.getByRole('link', { name: 'nixCraft 🐧', exact: true })).toBeVisible()
await expect(page.getByRole('link', { name: 'Avatar of Penny' })).toBeVisible()
await expect(page.getByRole('link', { name: 'Penny', exact: true })).toBeVisible()
await expect(page.getByText('Yes you guys did it!')).toBeVisible()
})

Wyświetl plik

@ -29,43 +29,43 @@ test('Presence of appropriate SEO metadata across the application', async ({ pag
})
await page.goto('http://127.0.0.1:8788/explore')
await page.locator('article').filter({ hasText: 'Hi, meet HiDock' }).locator('i.fa-globe + span').click()
await page
.locator('article')
.filter({ hasText: "I'm Rafael and I am a web designer!" })
.locator('i.fa-globe + span')
.click()
await checkPageSeoData(page, {
title: "Rafa: Hi, meet HiDock! It's a free M… - Wildebeest",
description:
"Hi, meet HiDock! It's a free Mac app that lets you set different Dock settings for different display configurations https://hidock.app →",
title: "Raffa123$: I'm Rafael and I am a web desi… - Wildebeest",
description: "I'm Rafael and I am a web designer! 💪💪",
ogType: 'article',
ogUrl: /https:\/\/127.0.0.1\/statuses\/[\w-]*\/?/,
ogImage: 'https://cdn.masto.host/mastodondesign/accounts/avatars/000/011/932/original/8f601be03c98b2e8.png',
ogImage: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/157.jpg',
})
await page.goto('http://127.0.0.1:8788/@rafa')
await page.goto('http://127.0.0.1:8788/@Ben')
await checkPageSeoData(page, {
title: 'Rafa (@rafa@0.0.0.0) - Wildebeest',
description: 'Rafa account page - Wildebeest',
title: 'Ben, just Ben (@Ben@0.0.0.0) - Wildebeest',
description: 'Ben, just Ben account page - Wildebeest',
ogType: 'article',
ogUrl: 'https://0.0.0.0/@rafa',
ogImage: 'https://cdn.masto.host/mastodondesign/accounts/avatars/000/011/932/original/8f601be03c98b2e8.png',
ogUrl: 'https://0.0.0.0/@Ben',
ogImage: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/1148.jpg',
})
await page.goto('http://127.0.0.1:8788/explore')
await page.locator('article').filter({ hasText: 'Ken White' }).locator('i.fa-globe + span').click()
await page.locator('article').filter({ hasText: 'Ben, just Ben' }).locator('i.fa-globe + span').click()
await checkPageSeoData(page, {
title: 'Ken White: Just recorded the first Seriou… - Wildebeest',
description:
'Just recorded the first Serious Trouble episode of the new year, out tomorrow. This week: George Santos is in serious trouble. Sam Bankman-Fried is in REALLY serious trouble. And Scott Adams is still making dumb defamation threats.',
title: 'Ben, just Ben: A very simple update: all good… - Wildebeest',
description: 'A very simple update: all good!',
ogType: 'article',
ogUrl: /https:\/\/127.0.0.1\/statuses\/[\w-]*\/?/,
ogImage: 'https://files.mastodon.social/accounts/avatars/109/502/260/753/916/593/original/f721da0f38083abf.jpg',
ogImage: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/1148.jpg',
})
await page.goto('http://127.0.0.1:8788/@Popehat')
await page.goto('http://127.0.0.1:8788/@NonExistent')
await checkPageSeoData(page, {
title: 'Ken White (@Popehat@0.0.0.0) - Wildebeest',
description: 'Ken White account page - Wildebeest',
ogType: 'article',
ogUrl: 'https://0.0.0.0/@Popehat',
ogImage: 'https://files.mastodon.social/accounts/avatars/109/502/260/753/916/593/original/f721da0f38083abf.jpg',
title: 'Wildebeest Not Found',
description: 'Wildebeest Page Not Found',
ogType: 'website',
})
})
@ -73,8 +73,8 @@ type ExpectedSeoValues = {
title: string | RegExp
description: string | RegExp
ogType: 'website' | 'article'
ogUrl: string | RegExp
ogImage: string | RegExp
ogUrl?: string | RegExp
ogImage?: string | RegExp
}
async function checkPageSeoData(page: Page, expected: Partial<ExpectedSeoValues>) {