update dependencies, add songs to youtube playlist
This commit is contained in:
@ -18,6 +18,12 @@ export const handleError = (({ error }) => {
|
||||
}) satisfies HandleServerError;
|
||||
|
||||
export const handle = (async ({ event, resolve }) => {
|
||||
const searchParams = event.url.searchParams;
|
||||
const authCode = searchParams.get('code');
|
||||
if (authCode) {
|
||||
log.debug('received GET hook', event.url.searchParams);
|
||||
}
|
||||
|
||||
// Reeder *insists* on checking /feed instead of /feed.xml
|
||||
if (event.url.pathname === '/feed') {
|
||||
return new Response('', { status: 301, headers: { Location: '/feed.xml' } });
|
||||
|
@ -16,6 +16,17 @@ export interface Post {
|
||||
songs?: SongInfo[];
|
||||
}
|
||||
|
||||
export interface OauthResponse {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
expires?: Date;
|
||||
refresh_token?: string;
|
||||
refresh_token_expires_in?: number;
|
||||
scope: string;
|
||||
token_type: string;
|
||||
error?: any;
|
||||
}
|
||||
|
||||
export interface PreviewCard {
|
||||
url: string;
|
||||
title: string;
|
||||
|
@ -26,10 +26,13 @@ import {
|
||||
saveSongThumbnail
|
||||
} from '$lib/server/db';
|
||||
import { createFeed, saveAtomFeed } from '$lib/server/rss';
|
||||
import { YoutubePlaylistAdder } from '$lib/server/ytPlaylistAdder';
|
||||
import { sleep } from '$lib/sleep';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs/promises';
|
||||
import { console } from 'inspector/promises';
|
||||
import sharp from 'sharp';
|
||||
import { URL, URLSearchParams } from 'url';
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
const URL_REGEX = new RegExp(/href="(?<postUrl>[^>]+?)" target="_blank"/gm);
|
||||
@ -40,10 +43,13 @@ const YOUTUBE_REGEX = new RegExp(
|
||||
|
||||
export class TimelineReader {
|
||||
private static _instance: TimelineReader;
|
||||
private lastPosts: string[] = [];
|
||||
private youtubePlaylistAdder: YoutubePlaylistAdder;
|
||||
|
||||
private static async isMusicVideo(videoId: string) {
|
||||
if (!YOUTUBE_API_KEY || YOUTUBE_API_KEY === 'CHANGE_ME') {
|
||||
// Assume that it *is* a music link when no YT API key is provided
|
||||
log.debug('YT API not configured');
|
||||
return true;
|
||||
}
|
||||
const searchParams = new URLSearchParams([
|
||||
@ -55,13 +61,13 @@ export class TimelineReader {
|
||||
const resp = await fetch(youtubeVideoUrl);
|
||||
const respObj = await resp.json();
|
||||
if (!respObj.items.length) {
|
||||
console.warn('Could not find video with id', videoId);
|
||||
log.warn('Could not find video with id', videoId);
|
||||
return false;
|
||||
}
|
||||
|
||||
const item = respObj.items[0];
|
||||
if (!item.snippet) {
|
||||
console.warn('Could not load snippet for video', videoId, item);
|
||||
log.warn('Could not load snippet for video', videoId, item);
|
||||
return false;
|
||||
}
|
||||
if (item.snippet.tags?.includes('music')) {
|
||||
@ -79,6 +85,7 @@ export class TimelineReader {
|
||||
const categoryTitle: string = await fetch(youtubeCategoryUrl)
|
||||
.then((r) => r.json())
|
||||
.then((r) => r.items[0]?.snippet?.title);
|
||||
log.debug('YT category', categoryTitle);
|
||||
return categoryTitle === 'Music';
|
||||
}
|
||||
|
||||
@ -102,7 +109,7 @@ export class TimelineReader {
|
||||
// Check *all* found url and let odesli determine if it is music or not
|
||||
log.debug(`Checking ${url} if it contains song data`);
|
||||
const info = await TimelineReader.getSongInfo(url);
|
||||
log.debug(`Found song info for ${url}?`, info);
|
||||
//log.debug(`Found song info for ${url}?`, info);
|
||||
if (info) {
|
||||
songs.push(info);
|
||||
}
|
||||
@ -144,6 +151,7 @@ export class TimelineReader {
|
||||
return null;
|
||||
}
|
||||
const info = odesliInfo.entitiesByUniqueId[odesliInfo.entityUniqueId];
|
||||
//log.debug('odesli response', info);
|
||||
const platform: Platform = 'youtube';
|
||||
if (info.platforms.includes(platform)) {
|
||||
const youtubeId =
|
||||
@ -156,7 +164,7 @@ export class TimelineReader {
|
||||
}
|
||||
const isMusic = await TimelineReader.isMusicVideo(youtubeId);
|
||||
if (!isMusic) {
|
||||
log.debug('Probably not a music video', url);
|
||||
log.debug('Probably not a music video', youtubeId, url);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -177,6 +185,88 @@ export class TimelineReader {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
private async addToYoutubePlaylist(song: SongInfo) {
|
||||
log.debug('addToYoutubePlaylist');
|
||||
let token: OauthResponse;
|
||||
try {
|
||||
const youtube_token_file = await fs.readFile('yt_auth_token', { encoding: 'utf8' });
|
||||
token = JSON.parse(youtube_token_file);
|
||||
log.debug('read youtube access token', token);
|
||||
} catch (e) {
|
||||
log.error('Could not read youtube access token', e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!YOUTUBE_PLAYLIST_ID || YOUTUBE_PLAYLIST_ID === 'CHANGE_ME') {
|
||||
log.debug('no playlist ID configured');
|
||||
return;
|
||||
}
|
||||
if (!song.youtubeUrl) {
|
||||
log.debug('Skip adding song to YT playlist, no youtube Url', song);
|
||||
return;
|
||||
}
|
||||
|
||||
const songUrl = new URL(song.youtubeUrl);
|
||||
const youtubeId = songUrl.searchParams.get('v');
|
||||
if (!youtubeId) {
|
||||
log.debug(
|
||||
'Skip adding song to YT playlist, could not extract YT id from URL',
|
||||
song.youtubeUrl
|
||||
);
|
||||
return;
|
||||
}
|
||||
log.debug('Found YT id from URL', song.youtubeUrl, youtubeId);
|
||||
|
||||
const playlistItemsUrl = new URL('https://www.googleapis.com/youtube/v3/playlistItems');
|
||||
playlistItemsUrl.searchParams.append('videoId', youtubeId);
|
||||
playlistItemsUrl.searchParams.append('playlistId', YOUTUBE_PLAYLIST_ID);
|
||||
playlistItemsUrl.searchParams.append('part', 'id');
|
||||
const existingPlaylistItem = await fetch(
|
||||
'https://www.googleapis.com/youtube/v3/playlistItems',
|
||||
{
|
||||
headers: { Authorization: `${token.token_type} ${token.access_token}` }
|
||||
}
|
||||
).then((r) => r.json());
|
||||
log.debug('existingPlaylistItem', existingPlaylistItem);
|
||||
if (existingPlaylistItem.pageInfo && existingPlaylistItem.pageInfo.totalResults > 0) {
|
||||
log.info('Item already in playlist');
|
||||
return;
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams([
|
||||
['part', 'snippet']
|
||||
//['key', token.access_token]
|
||||
]);
|
||||
const options: RequestInit = {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `${token.token_type} ${token.access_token}` },
|
||||
body: JSON.stringify({
|
||||
snippet: {
|
||||
playlistId: YOUTUBE_PLAYLIST_ID,
|
||||
resourceId: {
|
||||
videoId: youtubeId,
|
||||
kind: 'youtube#video'
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
const youtubeApiUrl = new URL(
|
||||
`https://www.googleapis.com/youtube/v3/playlistItems?${searchParams}`
|
||||
);
|
||||
const resp = await fetch(youtubeApiUrl, options);
|
||||
const respObj = await resp.json();
|
||||
log.debug('Added to playlist', options, respObj);
|
||||
if (respObj.error) {
|
||||
log.debug('Add to playlist failed', respObj.error.errors);
|
||||
}
|
||||
}
|
||||
*/
|
||||
private async addToPlaylist(song: SongInfo) {
|
||||
//await this.addToYoutubePlaylist(song);
|
||||
await this.youtubePlaylistAdder.addToPlaylist(song);
|
||||
}
|
||||
|
||||
private static async resizeAvatar(
|
||||
baseName: string,
|
||||
size: number,
|
||||
@ -370,10 +460,15 @@ export class TimelineReader {
|
||||
await TimelineReader.saveAvatar(post.account);
|
||||
await TimelineReader.saveSongThumbnails(songs);
|
||||
|
||||
log.debug('Saved post', post.url);
|
||||
log.debug('Saved post', post.url, 'songs', songs);
|
||||
|
||||
const posts = await getPosts(null, null, 100);
|
||||
await saveAtomFeed(createFeed(posts));
|
||||
|
||||
for (let song of songs) {
|
||||
log.debug('Adding to playlist', song);
|
||||
await this.addToPlaylist(song);
|
||||
}
|
||||
}
|
||||
|
||||
private startWebsocket() {
|
||||
@ -385,12 +480,76 @@ export class TimelineReader {
|
||||
};
|
||||
socket.onmessage = async (event) => {
|
||||
try {
|
||||
/*
|
||||
let token: OauthResponse;
|
||||
try {
|
||||
const youtube_token_file = await fs.readFile('yt_auth_token', { encoding: 'utf8' });
|
||||
token = JSON.parse(youtube_token_file);
|
||||
if (token.expires) {
|
||||
if (typeof token.expires === typeof '') {
|
||||
token.expires = new Date(token.expires);
|
||||
}
|
||||
let now = new Date();
|
||||
now.setTime(now.getTime() - 15 * 60 * 1000);
|
||||
log.info('token expiry', token.expires, 'vs refresh @', now);
|
||||
if (token.expires.getTime() <= now.getTime()) {
|
||||
log.info(
|
||||
'YT token expires',
|
||||
token.expires,
|
||||
token.expires.getTime(),
|
||||
'which is less than 15 minutes from now',
|
||||
now,
|
||||
now.getTime()
|
||||
);
|
||||
const tokenUrl = new URL('https://oauth2.googleapis.com/token');
|
||||
const params = new URLSearchParams();
|
||||
params.append('client_id', YOUTUBE_CLIENT_ID);
|
||||
params.append('client_secret', YOUTUBE_CLIENT_SECRET);
|
||||
params.append('refresh_token', token.refresh_token || '');
|
||||
params.append('grant_type', 'refresh_token');
|
||||
params.append('redirect_uri', `${BASE_URL}/ytauth`);
|
||||
if (token.refresh_token) {
|
||||
log.debug('sending token req', params);
|
||||
const resp = await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
body: params
|
||||
}).then((r) => r.json());
|
||||
if (!resp.error) {
|
||||
if (!resp.refresh_token) {
|
||||
resp.refresh_token = token.refresh_token;
|
||||
}
|
||||
let expiration = new Date();
|
||||
expiration.setSeconds(expiration.getSeconds() + resp.expires_in);
|
||||
resp.expires = expiration;
|
||||
await fs.writeFile('yt_auth_token', JSON.stringify(resp));
|
||||
} else {
|
||||
log.error('token resp error', resp);
|
||||
}
|
||||
} else {
|
||||
log.error('no refresg token');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log.error('onmessage Could not read youtube access token', e);
|
||||
}
|
||||
*/
|
||||
|
||||
const data: TimelineEvent = JSON.parse(event.data.toString());
|
||||
log.debug('ES event', data.event);
|
||||
if (data.event !== 'update') {
|
||||
log.log('Ignoring ES event', data.event);
|
||||
return;
|
||||
}
|
||||
const post: Post = JSON.parse(data.payload);
|
||||
if (this.lastPosts.includes(post.id)) {
|
||||
log.log('Skipping post, already handled', post.id);
|
||||
return;
|
||||
}
|
||||
this.lastPosts.push(post.id);
|
||||
while (this.lastPosts.length > 10) {
|
||||
this.lastPosts.shift();
|
||||
}
|
||||
await this.checkAndSavePost(post);
|
||||
} catch (e) {
|
||||
log.error('error message', event, event.data, e);
|
||||
@ -416,7 +575,11 @@ export class TimelineReader {
|
||||
private async loadPostsSinceLastRun() {
|
||||
const now = new Date().toISOString();
|
||||
let latestPost = await getPosts(null, now, 1);
|
||||
log.log('Last post in DB since', now, latestPost);
|
||||
if (latestPost.length > 0) {
|
||||
log.log('Last post in DB since', now, latestPost[0].created_at);
|
||||
} else {
|
||||
log.log('No posts in DB since');
|
||||
}
|
||||
let u = new URL(`https://${MASTODON_INSTANCE}/api/v1/timelines/public?local=true&limit=40`);
|
||||
if (latestPost.length > 0) {
|
||||
u.searchParams.append('since_id', latestPost[0].id);
|
||||
@ -428,7 +591,7 @@ export class TimelineReader {
|
||||
Authorization: `Bearer ${MASTODON_ACCESS_TOKEN}`
|
||||
};
|
||||
const latestPosts: Post[] = await fetch(u, { headers }).then((r) => r.json());
|
||||
log.info('searched posts', latestPosts);
|
||||
log.info('searched posts', latestPosts.length);
|
||||
for (const post of latestPosts) {
|
||||
await this.checkAndSavePost(post);
|
||||
}
|
||||
@ -436,6 +599,7 @@ export class TimelineReader {
|
||||
|
||||
private constructor() {
|
||||
log.log('Constructing timeline object');
|
||||
this.youtubePlaylistAdder = new YoutubePlaylistAdder();
|
||||
this.startWebsocket();
|
||||
|
||||
this.loadPostsSinceLastRun()
|
||||
|
194
src/lib/server/ytPlaylistAdder.ts
Normal file
194
src/lib/server/ytPlaylistAdder.ts
Normal file
@ -0,0 +1,194 @@
|
||||
import {
|
||||
BASE_URL,
|
||||
YOUTUBE_CLIENT_ID,
|
||||
YOUTUBE_CLIENT_SECRET,
|
||||
YOUTUBE_PLAYLIST_ID
|
||||
} from '$env/static/private';
|
||||
import { log } from '$lib/log';
|
||||
import type { OauthResponse } from '$lib/mastodon/response';
|
||||
import type { SongInfo } from '$lib/odesliResponse';
|
||||
import fs from 'fs/promises';
|
||||
|
||||
export class YoutubePlaylistAdder {
|
||||
private apiBase: string = 'https://www.googleapis.com/youtube/v3';
|
||||
private token_file_name: string = 'yt_auth_token';
|
||||
|
||||
/// How many minutes before expiry the token will be refreshed
|
||||
private refresh_time: number = 15;
|
||||
|
||||
public async authCodeExists(): Promise<boolean> {
|
||||
try {
|
||||
const fileHandle = await fs.open(this.token_file_name);
|
||||
await fileHandle.close();
|
||||
return true;
|
||||
} catch {
|
||||
log.info('No auth token yet, authorizing...');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public constructAuthUrl(redirectUri: URL): URL {
|
||||
const endpoint = 'https://accounts.google.com/o/oauth2/v2/auth';
|
||||
const authUrl = new URL(endpoint);
|
||||
authUrl.searchParams.append('client_id', YOUTUBE_CLIENT_ID);
|
||||
authUrl.searchParams.append('redirect_uri', redirectUri.toString());
|
||||
authUrl.searchParams.append('response_type', 'code');
|
||||
authUrl.searchParams.append('scope', 'https://www.googleapis.com/auth/youtube');
|
||||
authUrl.searchParams.append('access_type', 'offline');
|
||||
authUrl.searchParams.append('include_granted_scopes', 'false');
|
||||
return authUrl;
|
||||
}
|
||||
|
||||
public async receivedAuthCode(code: string, url: URL) {
|
||||
log.debug('received code');
|
||||
const tokenUrl = new URL('https://oauth2.googleapis.com/token');
|
||||
const params = new URLSearchParams();
|
||||
params.append('client_id', YOUTUBE_CLIENT_ID);
|
||||
params.append('client_secret', YOUTUBE_CLIENT_SECRET);
|
||||
params.append('code', code);
|
||||
params.append('grant_type', 'authorization_code');
|
||||
params.append('redirect_uri', `${url.origin}${url.pathname}`);
|
||||
log.debug('sending token req', params);
|
||||
const resp: OauthResponse = await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
body: params
|
||||
}).then((r) => r.json());
|
||||
log.debug('received access token', resp);
|
||||
let expiration = new Date();
|
||||
expiration.setTime(expiration.getTime() + resp.expires_in * 1000);
|
||||
expiration.setSeconds(expiration.getSeconds() + resp.expires_in);
|
||||
resp.expires = expiration;
|
||||
await fs.writeFile(this.token_file_name, JSON.stringify(resp));
|
||||
}
|
||||
|
||||
private async auth(): Promise<OauthResponse | null> {
|
||||
try {
|
||||
const youtube_token_file = await fs.readFile(this.token_file_name, { encoding: 'utf8' });
|
||||
let token = JSON.parse(youtube_token_file);
|
||||
log.debug('read youtube access token', token);
|
||||
if (token.expires) {
|
||||
if (typeof token.expires === typeof '') {
|
||||
token.expires = new Date(token.expires);
|
||||
}
|
||||
}
|
||||
return token;
|
||||
} catch (e) {
|
||||
log.error('Could not read youtube access token', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshToken(): Promise<OauthResponse | null> {
|
||||
const token = await this.auth();
|
||||
if (token == null || !token?.expires) {
|
||||
return null;
|
||||
}
|
||||
let now = new Date();
|
||||
now.setTime(now.getTime() - this.refresh_time * 60 * 1000);
|
||||
log.info('token expiry', token.expires, 'vs refresh @', now);
|
||||
if (token.expires.getTime() > now.getTime()) {
|
||||
return token;
|
||||
}
|
||||
|
||||
log.info(
|
||||
'YT token expires',
|
||||
token.expires,
|
||||
token.expires.getTime(),
|
||||
`which is less than ${this.refresh_time} minutes from now`,
|
||||
now,
|
||||
now.getTime()
|
||||
);
|
||||
|
||||
const tokenUrl = new URL('https://oauth2.googleapis.com/token');
|
||||
const params = new URLSearchParams();
|
||||
params.append('client_id', YOUTUBE_CLIENT_ID);
|
||||
params.append('client_secret', YOUTUBE_CLIENT_SECRET);
|
||||
params.append('refresh_token', token.refresh_token || '');
|
||||
params.append('grant_type', 'refresh_token');
|
||||
params.append('redirect_uri', `${BASE_URL}/ytauth`);
|
||||
if (!token.refresh_token) {
|
||||
log.error('Need to refresh access token, but no refresh token provided');
|
||||
return null;
|
||||
}
|
||||
log.debug('sending token req', params);
|
||||
let resp: OauthResponse = await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
body: params
|
||||
}).then((r) => r.json());
|
||||
if (resp.error) {
|
||||
log.error('token resp error', resp);
|
||||
return null;
|
||||
}
|
||||
if (!resp.refresh_token) {
|
||||
resp.refresh_token = token.refresh_token;
|
||||
}
|
||||
let expiration = new Date();
|
||||
expiration.setSeconds(expiration.getSeconds() + resp.expires_in);
|
||||
resp.expires = expiration;
|
||||
await fs.writeFile(this.token_file_name, JSON.stringify(resp));
|
||||
return resp;
|
||||
}
|
||||
|
||||
public async addToPlaylist(song: SongInfo) {
|
||||
log.debug('addToYoutubePlaylist');
|
||||
const token = await this.refreshToken();
|
||||
if (token == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!YOUTUBE_PLAYLIST_ID || YOUTUBE_PLAYLIST_ID === 'CHANGE_ME') {
|
||||
log.debug('no playlist ID configured');
|
||||
return;
|
||||
}
|
||||
if (!song.youtubeUrl) {
|
||||
log.debug('Skip adding song to YT playlist, no youtube Url', song);
|
||||
return;
|
||||
}
|
||||
|
||||
const songUrl = new URL(song.youtubeUrl);
|
||||
const youtubeId = songUrl.searchParams.get('v');
|
||||
if (!youtubeId) {
|
||||
log.debug(
|
||||
'Skip adding song to YT playlist, could not extract YT id from URL',
|
||||
song.youtubeUrl
|
||||
);
|
||||
return;
|
||||
}
|
||||
log.debug('Found YT id from URL', song.youtubeUrl, youtubeId);
|
||||
|
||||
const playlistItemsUrl = new URL(this.apiBase + '/playlistItems');
|
||||
playlistItemsUrl.searchParams.append('videoId', youtubeId);
|
||||
playlistItemsUrl.searchParams.append('playlistId', YOUTUBE_PLAYLIST_ID);
|
||||
playlistItemsUrl.searchParams.append('part', 'id');
|
||||
const existingPlaylistItem = await fetch(this.apiBase + '/playlistItems', {
|
||||
headers: { Authorization: `${token.token_type} ${token.access_token}` }
|
||||
}).then((r) => r.json());
|
||||
log.debug('existingPlaylistItem', existingPlaylistItem);
|
||||
if (existingPlaylistItem.pageInfo && existingPlaylistItem.pageInfo.totalResults > 0) {
|
||||
log.info('Item already in playlist');
|
||||
return;
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams([['part', 'snippet']]);
|
||||
const options: RequestInit = {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `${token.token_type} ${token.access_token}` },
|
||||
body: JSON.stringify({
|
||||
snippet: {
|
||||
playlistId: YOUTUBE_PLAYLIST_ID,
|
||||
resourceId: {
|
||||
videoId: youtubeId,
|
||||
kind: 'youtube#video'
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
const youtubeApiUrl = new URL(`${this.apiBase}/playlistItems?${searchParams}`);
|
||||
const resp = await fetch(youtubeApiUrl, options);
|
||||
const respObj = await resp.json();
|
||||
log.debug('Added to playlist', options, respObj);
|
||||
if (respObj.error) {
|
||||
log.debug('Add to playlist failed', respObj.error.errors);
|
||||
}
|
||||
}
|
||||
}
|
24
src/routes/ytauth/+page.server.ts
Normal file
24
src/routes/ytauth/+page.server.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { log } from '$lib/log';
|
||||
import { YoutubePlaylistAdder } from '$lib/server/ytPlaylistAdder';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ url }) => {
|
||||
const adder = new YoutubePlaylistAdder();
|
||||
if (url.searchParams.has('code')) {
|
||||
log.debug(url.searchParams);
|
||||
await adder.receivedAuthCode(url.searchParams.get('code') || '', url);
|
||||
redirect(307, '/');
|
||||
} else if (url.searchParams.has('error')) {
|
||||
log.error('received error', url.searchParams.get('error'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (await adder.authCodeExists()) {
|
||||
redirect(307, '/');
|
||||
}
|
||||
|
||||
const authUrl = adder.constructAuthUrl(url);
|
||||
log.debug('+page.server.ts', authUrl.toString());
|
||||
redirect(307, authUrl);
|
||||
};
|
2
src/routes/ytauth/+page.svelte
Normal file
2
src/routes/ytauth/+page.svelte
Normal file
@ -0,0 +1,2 @@
|
||||
<h1>Hello and welcome to my site!</h1>
|
||||
<a href="/about">About my site</a>
|
Reference in New Issue
Block a user