Compare commits

...

6 Commits

Author SHA1 Message Date
ce0871b052
feat: store 'bridged' posts in note data and display it
All checks were successful
create archive with lfs / tag (push) Successful in 12s
2024-11-23 04:28:16 +03:00
22c773c38e
refactor: better organize some code 2024-11-23 03:00:36 +03:00
3e640524e8
refactor: move visit handling code into its own lib file 2024-11-23 02:53:27 +03:00
6bc35b1629
refactor: move lastfm code into its own lib file 2024-11-23 02:46:42 +03:00
c06b5b010c
refactor: move steam code into its own lib file 2024-11-23 02:45:52 +03:00
61bec8904e
refactor: move bsky code into its own lib file 2024-11-23 02:40:32 +03:00
10 changed files with 218 additions and 157 deletions

31
src/lib/bluesky.ts Normal file
View File

@ -0,0 +1,31 @@
import { env } from '$env/dynamic/private'
import { Agent, CredentialSession, RichText } from '@atproto/api'
import { get, writable } from 'svelte/store'
const bskyClient = writable<null | Agent>(null)
export const getBskyClient = async () => {
let client = get(bskyClient)
if (client === null) {
client = await loginToBsky()
bskyClient.set(client)
}
return client
}
export const postToBsky = async (text: string) => {
let client = await getBskyClient()
const rt = new RichText({ text })
await rt.detectFacets(client)
const {uri} = await client.post({
text: rt.text,
facets: rt.facets,
})
return uri
}
const loginToBsky = async () => {
const creds = new CredentialSession(new URL("https://bsky.social"))
await creds.login({ identifier: 'gaze.systems', password: env.BSKY_PASSWORD ?? "" })
return new Agent(creds)
}

View File

@ -1,9 +1,4 @@
import type { Cookies } from '@sveltejs/kit' import type { Cookies } from '@sveltejs/kit'
import { env } from '$env/dynamic/private'
import { get, writable } from 'svelte/store'
import { existsSync, readFileSync } from 'fs'
import { Agent, CredentialSession } from '@atproto/api'
import SGDB from 'steamgriddb'
export const scopeCookies = (cookies: Cookies, path: string) => { export const scopeCookies = (cookies: Cookies, path: string) => {
return { return {
@ -17,79 +12,4 @@ export const scopeCookies = (cookies: Cookies, path: string) => {
cookies.delete(key, { ...props, path }) cookies.delete(key, { ...props, path })
} }
} }
}
export const visitCountFile = `${env.WEBSITE_DATA_DIR}/visitcount`
export const visitCount = writable(parseInt(existsSync(visitCountFile) ? readFileSync(visitCountFile).toString() : '0'));
export const loginToBsky = async () => {
const creds = new CredentialSession(new URL("https://bsky.social"))
await creds.login({ identifier: 'gaze.systems', password: env.BSKY_PASSWORD ?? "" })
return new Agent(creds)
}
export const bskyClient = writable<null | Agent>(null)
const cachedLastTrack = writable<{track: LastTrack | null, since: number}>({track: null, since: 0})
export type LastTrack = {name: string, artist: string, image: string | null, link: string}
export const lastFmGetNowPlaying: () => Promise<LastTrack | null> = async () => {
var cached = get(cachedLastTrack)
if (Date.now() - cached.since < 10 * 1000) {
return cached.track
}
try {
const API_URL = "https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=yusdacra&api_key=da1911d405b5b37383e200b8f36ee9ec&format=json&limit=1"
var resp = await (await fetch(API_URL)).json()
var track = resp.recenttracks.track[0] ?? null
if (!(track['@attr'].nowplaying ?? null)) {
throw "no nowplaying track found"
}
var data = {
name: track.name,
artist: track.artist['#text'],
image: track.image[2]['#text'] ?? null,
link: track.url,
}
cachedLastTrack.set({track: data, since: Date.now()})
return data
} catch(why) {
console.log("could not fetch last fm: ", why)
cachedLastTrack.set({track: null, since: Date.now()})
return null
}
}
const steamgriddbClient = writable<SGDB | null>(null);
const cachedLastGame = writable<{game: LastGame | null, since: number}>({game: null, since: 0})
export type LastGame = {name: string, link: string, icon: string, pfp: string}
export const steamGetNowPlaying: () => Promise<LastGame | null> = async () => {
var griddbClient = get(steamgriddbClient)
if (griddbClient === null) {
griddbClient = new SGDB(env.STEAMGRIDDB_API_KEY)
steamgriddbClient.set(griddbClient)
}
var cached = get(cachedLastGame)
if (Date.now() - cached.since < 10 * 1000) {
return cached.game
}
try {
const API_URL = `http://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=${env.STEAM_API_KEY}&steamids=76561198106829949&format=json`
var profile = (await (await fetch(API_URL)).json()).response.players[0]
if (!profile.gameid) {
throw "no game is being played"
}
var icons = await griddbClient.getIconsBySteamAppId(profile.gameid, ['official'])
console.log(icons)
var game = {
name: profile.gameextrainfo,
link: `https://store.steampowered.com/app/${profile.gameid}`,
icon: icons[0].thumb.toString(),
pfp: profile.avatarmedium,
}
cachedLastGame.set({game, since: Date.now()})
return game
} catch(why) {
console.log("could not fetch steam: ", why)
cachedLastGame.set({game: null, since: Date.now()})
return null
}
} }

34
src/lib/lastfm.ts Normal file
View File

@ -0,0 +1,34 @@
import { get, writable } from "svelte/store"
const GET_RECENT_TRACKS_ENDPOINT = "https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=yusdacra&api_key=da1911d405b5b37383e200b8f36ee9ec&format=json&limit=1"
const CACHE_EXPIRY_SECONDS = 10
type LastTrack = {name: string, artist: string, image: string | null, link: string}
type CachedLastTrack = {track: LastTrack | null, since: number}
const cachedLastTrack = writable<CachedLastTrack>({track: null, since: 0})
export const lastFmGetNowPlaying: () => Promise<LastTrack | null> = async () => {
var cached = get(cachedLastTrack)
if (Date.now() - cached.since < CACHE_EXPIRY_SECONDS * 1000) {
return cached.track
}
try {
var resp = await (await fetch(GET_RECENT_TRACKS_ENDPOINT)).json()
var track = resp.recenttracks.track[0] ?? null
if (!(track['@attr'].nowplaying ?? null)) {
throw "no nowplaying track found"
}
var data = {
name: track.name,
artist: track.artist['#text'],
image: track.image[2]['#text'] ?? null,
link: track.url,
}
cachedLastTrack.set({track: data, since: Date.now()})
return data
} catch(why) {
console.log("could not fetch last fm: ", why)
cachedLastTrack.set({track: null, since: Date.now()})
return null
}
}

View File

@ -2,9 +2,15 @@ import { existsSync, readFileSync, writeFileSync } from 'fs'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import { env } from '$env/dynamic/private' import { env } from '$env/dynamic/private'
export interface OutgoingLinkData {
name: string,
link: string,
}
export interface Note { export interface Note {
content: string, content: string,
published: number, published: number,
outgoingLinks?: OutgoingLinkData[],
} }
type NoteId = string type NoteId = string
@ -32,8 +38,7 @@ export const writeNote = (id: NoteId, note: Note) => {
writeNotesList([id].concat(noteList)) writeNotesList([id].concat(noteList))
} }
} }
export const createNote = (note: Note) => { export const createNote = (id: NoteId, note: Note) => {
const id = genNoteId()
writeNote(id, note) writeNote(id, note)
return id return id
} }

45
src/lib/steam.ts Normal file
View File

@ -0,0 +1,45 @@
import { env } from "$env/dynamic/private";
import SGDB from "steamgriddb";
import { get, writable } from "svelte/store";
const STEAM_ID = "76561198106829949"
const GET_PLAYER_SUMMARY_ENDPOINT = `http://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=${env.STEAM_API_KEY}&steamids=${STEAM_ID}&format=json`
const CACHE_EXPIRY_SECONDS = 10
type LastGame = {name: string, link: string, icon: string, pfp: string}
type CachedLastGame = {game: LastGame | null, since: number}
const steamgriddbClient = writable<SGDB | null>(null);
const cachedLastGame = writable<CachedLastGame>({game: null, since: 0})
export const steamGetNowPlaying: () => Promise<LastGame | null> = async () => {
var griddbClient = get(steamgriddbClient)
if (griddbClient === null) {
griddbClient = new SGDB(env.STEAMGRIDDB_API_KEY)
steamgriddbClient.set(griddbClient)
}
var cached = get(cachedLastGame)
if (Date.now() - cached.since < CACHE_EXPIRY_SECONDS * 1000) {
return cached.game
}
try {
var profile = (await (await fetch(GET_PLAYER_SUMMARY_ENDPOINT)).json()).response.players[0]
if (!profile.gameid) {
throw "no game is being played"
}
var icons = await griddbClient.getIconsBySteamAppId(profile.gameid, ['official'])
console.log(icons)
var game = {
name: profile.gameextrainfo,
link: `https://store.steampowered.com/app/${profile.gameid}`,
icon: icons[0].thumb.toString(),
pfp: profile.avatarmedium,
}
cachedLastGame.set({game, since: Date.now()})
return game
} catch(why) {
console.log("could not fetch steam: ", why)
cachedLastGame.set({game: null, since: Date.now()})
return null
}
}

57
src/lib/visits.ts Normal file
View File

@ -0,0 +1,57 @@
import { env } from "$env/dynamic/private";
import { scopeCookies } from "$lib";
import type { Cookies } from "@sveltejs/kit";
import { existsSync, readFileSync, writeFileSync } from "fs";
import { get, writable } from "svelte/store";
const visitCountFile = `${env.WEBSITE_DATA_DIR}/visitcount`
const visitCount = writable(parseInt(existsSync(visitCountFile) ? readFileSync(visitCountFile).toString() : '0'));
export const incrementVisitCount = (request: Request, cookies: Cookies) => {
let currentVisitCount = get(visitCount)
// check whether the request is from a bot or not (this doesnt need to be accurate we just want to filter out honest bots)
const ua = request.headers.get('user-agent')
const isBot = ua ? ua.toLowerCase().match(/(bot|crawl|spider|walk)/) !== null : true
if (!isBot) {
const scopedCookies = scopeCookies(cookies, '/')
// parse the last visit timestamp from cookies if it exists
const visitedTimestamp = parseInt(scopedCookies.get('visitedTimestamp') || "0")
// get unix timestamp
const currentTime = new Date().getTime()
const timeSinceVisit = currentTime - visitedTimestamp
// check if this is the first time a client is visiting or if an hour has passed since they last visited
if (visitedTimestamp === 0 || timeSinceVisit > 1000 * 60 * 60 * 24) {
// increment current and write to the store
currentVisitCount += 1; visitCount.set(currentVisitCount)
// update the cookie with the current timestamp
scopedCookies.set('visitedTimestamp', currentTime.toString())
// write the visit count to a file so we can load it later again
writeFileSync(visitCountFile, currentVisitCount.toString())
}
}
return currentVisitCount
}
export const notifyDarkVisitors = (url: URL, request: Request) => {
fetch('https://api.darkvisitors.com/visits', {
method: 'POST',
headers: {
authorization: `Bearer ${env.DARK_VISITORS_TOKEN}`,
'content-type': 'application/json',
},
body: JSON.stringify({
request_path: url.pathname,
request_method: request.method,
request_headers: request.headers,
})
}).catch((why) => {
console.log("failed sending dark visitors analytics:", why)
return null
}).then(async (resp) => {
if (resp !== null) {
const msg = await resp.json()
const host = `(${request.headers.get('host')} ${request.headers.get('x-real-ip')})`
console.log(`sent visitor analytic to dark visitors: ${resp.statusText}; ${msg.message ?? ''}${host}`)
}
})
}

View File

@ -1,62 +1,17 @@
import { env } from '$env/dynamic/private'; import { incrementVisitCount, notifyDarkVisitors } from '$lib/visits.js';
import { scopeCookies, visitCount, visitCountFile } from '$lib';
import { writeFileSync } from 'fs';
import { get } from 'svelte/store';
export const csr = true; export const csr = true;
export const ssr = true; export const ssr = true;
export const prerender = false; export const prerender = false;
export const trailingSlash = 'always'; export const trailingSlash = 'always';
export async function load({ request, cookies, url, setHeaders, fetch }) { export async function load({ request, cookies, url, setHeaders }) {
fetch('https://api.darkvisitors.com/visits', { notifyDarkVisitors(url, request) // no await so it doesnt block load
method: 'POST',
headers: {
authorization: `Bearer ${env.DARK_VISITORS_TOKEN}`,
'content-type': 'application/json',
},
body: JSON.stringify({
request_path: url.pathname,
request_method: request.method,
request_headers: request.headers,
})
}).catch((why) => {
console.log("failed sending dark visitors analytics:", why)
return null
}).then(async (resp) => {
if (resp !== null) {
const msg = await resp.json()
const host = `(${request.headers.get('host')} ${request.headers.get('x-real-ip')})`
console.log(`sent visitor analytic to dark visitors: ${resp.statusText}; ${msg.message ?? ''}${host}`)
}
})
let currentVisitCount = get(visitCount)
// check whether the request is from a bot or not (this doesnt need to be accurate we just want to filter out honest bots)
const ua = request.headers.get('user-agent')
const isBot = ua ? ua.toLowerCase().match(/(bot|crawl|spider|walk)/) !== null : true
if (!isBot) {
const scopedCookies = scopeCookies(cookies, '/')
// parse the last visit timestamp from cookies if it exists
const visitedTimestamp = parseInt(scopedCookies.get('visitedTimestamp') || "0")
// get unix timestamp
const currentTime = new Date().getTime()
const timeSinceVisit = currentTime - visitedTimestamp
// check if this is the first time a client is visiting or if an hour has passed since they last visited
if (visitedTimestamp === 0 || timeSinceVisit > 1000 * 60 * 60 * 24) {
// increment current and write to the store
currentVisitCount += 1; visitCount.set(currentVisitCount)
// update the cookie with the current timestamp
scopedCookies.set('visitedTimestamp', currentTime.toString())
// write the visit count to a file so we can load it later again
writeFileSync(visitCountFile, currentVisitCount.toString())
}
}
setHeaders({ 'Cache-Control': 'no-cache' }) setHeaders({ 'Cache-Control': 'no-cache' })
return { return {
route: url.pathname, route: url.pathname,
visitCount: currentVisitCount, visitCount: incrementVisitCount(request, cookies),
} }
} }

View File

@ -1,4 +1,5 @@
import { lastFmGetNowPlaying, steamGetNowPlaying } from "$lib" import { lastFmGetNowPlaying } from "$lib/lastfm"
import { steamGetNowPlaying } from "$lib/steam"
export const load = async ({}) => { export const load = async ({}) => {
const lastTrack = await lastFmGetNowPlaying() const lastTrack = await lastFmGetNowPlaying()

View File

@ -15,6 +15,14 @@
} }
const highlightedNote = data.notes.get(data.highlightedNote ?? '') ?? null const highlightedNote = data.notes.get(data.highlightedNote ?? '') ?? null
// this is ASS this should be a tailwind class
const getTextShadowStyle = (color: string) => {
return `text-shadow: 0 0 1px theme(colors.ralsei.black), 0 0 5px ${color};`
}
const outgoingLinkColors: Record<string, string> = {
bsky: "rgb(0, 133, 255)",
}
</script> </script>
<svelte:head> <svelte:head>
@ -33,15 +41,19 @@
" "
> >
<pre class="language-bash"><code class="language-bash"><nobr> <pre class="language-bash"><code class="language-bash"><nobr>
<Token v="[" punct/>gazesystems <Token v="/log/" keywd/><Token v="]$" punct/> <Token v="source" funct/> log.nu <Token v="[" punct/>gazesystems <Token v="/" keywd/><Token v="]$" punct/> <Token v="source" funct/> <Token v="scripts/log.nu" />
<br> <br>
<Token v="[" punct/>gazesystems <Token v="/log/" keywd/><Token v="]$" punct/> <Token v="ls" funct/> log <Token v="|" punct/> <Token v="each" funct/> <Token v="&#123;" punct/><Token v="|" punct/>file<Token v="|" punct/> <Token v="render" funct/> <Token v="(" punct/><Token v="open" funct/> $file.name<Token v=")" punct/><Token v="&#125;" punct/> <Token v="[" punct/>gazesystems <Token v="/" keywd/><Token v="]$" punct/> <Token v="ls" funct/> <Token v="log" /> <Token v="|" punct/> <Token v="each" funct/> <Token v="&#123;" punct/><Token v="|" punct/><Token v="file"/><Token v="|" punct/> <Token v="render" funct/> <Token v="(" punct/><Token v="open" funct/> <Token v="$file.name" /><Token v=")" punct/><Token v="&#125;" punct/>
<br> <br>
<br> <br>
{#each data.notes as [noteId, note], index} {#each data.notes as [noteId, note], index}
{@const isHighlighted = noteId === data.highlightedNote} {@const isHighlighted = noteId === data.highlightedNote}
<div class="text-wrap break-words max-w-[70ch] leading-none"> <div class="text-wrap break-words max-w-[70ch] leading-none">
<Token v={renderDate(note.published)} small={!isHighlighted}/> <Token v={noteId} keywd small={!isHighlighted}/><Token v="#" punct/>&nbsp;&nbsp;<Token v={note.content} str/> <Token v={renderDate(note.published)} small={!isHighlighted}/> <Token v={noteId} keywd small={!isHighlighted}/><Token v="#" punct/>&nbsp;&nbsp;<Token v={note.content} str/>
{#each note.outgoingLinks ?? [] as {name, link}}
{@const color = outgoingLinkColors[name]}
<span class="text-sm"><Token v="(" punct/><a style="color: {color};{getTextShadowStyle(color)}" href={link}>{name}</a><Token v=")" punct/></span>
{/each}
</div> </div>
{#if index < data.notes.size - 1} {#if index < data.notes.size - 1}
<div class="mt-3"/> <div class="mt-3"/>

View File

@ -1,9 +1,7 @@
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import { PUBLIC_BASE_URL } from '$env/static/public'; import { PUBLIC_BASE_URL } from '$env/static/public';
import { bskyClient, loginToBsky } from '$lib'; import { postToBsky } from '$lib/bluesky';
import { createNote } from '$lib/notes.js'; import { createNote, genNoteId, type Note } from '$lib/notes';
import { RichText } from '@atproto/api';
import { get } from 'svelte/store';
interface NoteData { interface NoteData {
content: string, content: string,
@ -15,30 +13,33 @@ export const POST = async ({ request }) => {
if (token !== env.GAZEBOT_TOKEN) { if (token !== env.GAZEBOT_TOKEN) {
return new Response("rizz failed", { status: 403 }) return new Response("rizz failed", { status: 403 })
} }
// get id
const noteId = genNoteId()
// get note data // get note data
const noteData: NoteData = await request.json() const noteData: NoteData = await request.json()
console.log("want to create note with data: ", noteData) console.log(`want to create note #${noteId} with data: `, noteData)
// create note // get a date before we start publishing to other platforms
const published = Date.now() let note: Note = {
const noteId = createNote({ content: noteData.content, published }) content: noteData.content,
published: Date.now(),
outgoingLinks: [],
}
let errors: string[] = []
// bridge to bsky if want to bridge // bridge to bsky if want to bridge
if (noteData.bskyPosse) { if (noteData.bskyPosse) {
let client = get(bskyClient) const postContent = `${noteData.content} (${PUBLIC_BASE_URL}/log?id=${noteId})`
if (client === null) { try {
client = await loginToBsky() const bskyUrl = await postToBsky(postContent)
bskyClient.set(client) note.outgoingLinks?.push({name: "bsky", link: bskyUrl})
} catch(why) {
console.log(`failed to post note #${noteId} to bsky: `, why)
errors.push(`error while posting to bsky: ${why}`)
} }
const rt = new RichText({
text: `${noteData.content} (${PUBLIC_BASE_URL}/log?id=${noteId})`,
})
await rt.detectFacets(client)
await client.post({
text: rt.text,
facets: rt.facets,
})
} }
// send back created note id // create note (this should never fail otherwise it would defeat the whole purpose lol)
return new Response(JSON.stringify({ noteId }), { createNote(noteId, note)
// send back created note id and any errors that occurred
return new Response(JSON.stringify({ noteId, errors }), {
headers: { headers: {
'content-type': 'application/json', 'content-type': 'application/json',
'cache-control': 'no-store', 'cache-control': 'no-store',