Deno -> NodeJS

This commit is contained in:
flash 2025-05-20 23:30:44 +00:00
parent 9cd1d8e489
commit fe2204650a
Signed by: flash
GPG key ID: 2C9C2C574D47FE3E
24 changed files with 1456 additions and 413 deletions

1
.gitignore vendored
View file

@ -8,3 +8,4 @@
/lib/index-dev
/vendor
/node_modules
/.env

View file

@ -1,4 +1,4 @@
Copyright (c) 2021-2024, flashwave <me@flash.moe>
Copyright (c) 2021-2025, flashwave <me@flash.moe>
All rights reserved.
Redistribution and use in source and binary forms, with or without

28
deno.lock generated
View file

@ -1,28 +0,0 @@
{
"version": "4",
"specifiers": {
"jsr:@std/fs@*": "1.0.5",
"jsr:@std/path@*": "1.0.7",
"jsr:@std/path@^1.0.7": "1.0.7"
},
"jsr": {
"@std/fs@1.0.5": {
"integrity": "41806ad6823d0b5f275f9849a2640d87e4ef67c51ee1b8fb02426f55e02fd44e",
"dependencies": [
"jsr:@std/path@^1.0.7"
]
},
"@std/path@1.0.7": {
"integrity": "76a689e07f0e15dcc6002ec39d0866797e7156629212b28f27179b8a5c3b33a1"
}
},
"workspace": {
"packageJson": {
"dependencies": [
"npm:cheerio@1",
"npm:express@^5.0.1",
"npm:memcache-client@^1.0.5"
]
}
}
}

View file

@ -1,9 +0,0 @@
module.exports = {
apps: [
{
script: './uiharu.ts',
interpreter: 'deno',
interpreterArgs: 'run --allow-net --allow-read --allow-run --allow-env',
},
],
};

1129
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

13
package.json Normal file
View file

@ -0,0 +1,13 @@
{
"name": "Uiharu",
"description": "Uiharu has flowers in her hair and looks up details on URIs for you",
"main": "uiharu.js",
"type": "module",
"dependencies": {
"cheerio": "^1.0.0",
"color": "^5.0.0",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"express": "^5.1.0"
}
}

9
src/consts.js Normal file
View file

@ -0,0 +1,9 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
export const VERSION = '20250520';
export const IS_DEBUG = process.env.NODE_ENV !== 'production';
export const PORT = parseInt(process.env.UIHARU_PORT ?? '3009');
export const HOSTNAME = process.env.UIHARU_HOSTNAME ?? 'uiharu.flashii.net';
export const ALLOWED_ORIGINS = (origins => origins === '*' ? true : origins.split(' '))(process.env.UIHARU_ORIGINS ?? 'flashii.net chat.flashii.net sockchat.flashii.net');
export const CACHE_LIFETIME = parseInt(process.env.UIHARU_CACHE_LIFETIME ?? '600');

View file

@ -1,6 +0,0 @@
import { existsSync } from "jsr:@std/fs";
import { join } from "jsr:@std/path";
export const APP_VERSION: string = '20241029';
export const IS_DEBUG: bool = existsSync(join(import.meta.dirname, '/../.debug'));
export const PUBLIC_DIR: string = join(import.meta.dirname, '/../public');

61
src/cors.js Normal file
View file

@ -0,0 +1,61 @@
export function accessControlHandler(accessControl, allowedOrigins=true, maxAge=300) {
return (req, res, next) => {
const origin = req.get('Origin');
if(typeof origin === 'string' && origin.includes('://')) {
const preflight = req.method === 'OPTIONS';
const requestMethod = preflight ? req.get('Access-Control-Request-Method') : req.method;
if(typeof requestMethod === 'string' && typeof accessControl[req.url] === 'object') {
const allowAnyMethod = accessControl[req.url].methods === true;
if(allowAnyMethod || typeof accessControl[req.url].methods[requestMethod] === 'object') {
const originParts = origin.split('://', 2);
const acl = allowAnyMethod ? accessControl[req.url] : { ...accessControl[req.url], ...accessControl[req.url].methods[requestMethod] };
const origins = acl.origins ?? allowedOrigins;
const allowAnyOrigin = origins === true;
if(allowAnyOrigin || origins.includes(originParts[1]) || origins.includes(origin)) {
if(allowAnyOrigin)
res.set('Access-Control-Allow-Origin', '*');
else {
res.vary('Origin');
res.set('Access-Control-Allow-Origin', origin);
}
if(acl.credentials === true || (Array.isArray(acl.credentials) && (acl.credentials.includes(originParts[1]) || acl.credentials.includes(origin))))
res.set('Access-Control-Allow-Credentials', 'true');
if(preflight) {
if(acl.allowHeaders === true)
res.set('Access-Control-Allow-Headers', '*');
else if(Array.isArray(acl.allowHeaders)) {
const requestHeaders = (req.get('Access-Control-Request-Headers') ?? '').toLowerCase().split(',').map(value => value.trim());
res.set('Access-Control-Allow-Headers', acl.allowHeaders.filter(value => requestHeaders.includes(value.toLowerCase())).toSorted().join(', '));
}
if(allowAnyMethod)
res.set('Access-Control-Allow-Methods', '*');
else
res.set('Access-Control-Allow-Methods', Object.keys(acl.methods).toSorted().join(', '));
res.set('Access-Control-Max-Age', (acl.maxAge ?? maxAge).toString());
res.status(204).end();
return;
} else {
if(acl.exposeHeaders === true)
res.set('Access-Control-Expose-Headers', '*');
else if(acl.exposeHeaders === false)
res.set('Access-Control-Expose-Headers', '');
else if(Array.isArray(acl.exposeHeaders) && acl.exposeHeaders.length > 0)
res.set('Access-Control-Expose-Headers', acl.exposeHeaders.filter((value, index, array) => array.indexOf(value) === index).toSorted().join(', '));
}
}
}
}
}
next();
};
};

View file

@ -1,4 +1,4 @@
import { APP_VERSION } from './consts.ts';
import { VERSION } from './consts.js';
export async function fetchWithHeaders(url, init) {
if(!init)
@ -10,7 +10,7 @@ export async function fetchWithHeaders(url, init) {
if(!init.headers['Accept-Language'])
init.headers['Accept-Language'] = 'en-GB, en;q=0.9, ja-jp;q=0.6, *;q=0.5';
if(!init.headers['User-Agent'])
init.headers['User-Agent'] = `Mozilla/5.0 (compatible; Uiharu/${APP_VERSION}; +http://fii.moe/uiharu)`;
init.headers['User-Agent'] = `Mozilla/5.0 (compatible; Uiharu/${VERSION}; +http://fii.moe/uiharu)`;
return await fetch(url, init);
};

View file

@ -1,55 +0,0 @@
import { PUBLIC_DIR } from '../consts.ts';
import { normalize } from "jsr:@std/path/normalize";
import { existsSync } from "jsr:@std/fs";
const mediaTypes = {
'html': 'text/html;charset=utf-8',
'css': 'text/css;charset=utf-8',
'txt': 'text/plain;charset=utf-8',
'png': 'image/png',
};
function extractMediaType(path: string): string {
let mediaType: string = 'application/octet-stream';
const dotIndex = path.lastIndexOf('.');
if(dotIndex >= 0) {
const ext = path.substring(dotIndex + 1);
if(ext in mediaTypes)
mediaType = mediaTypes[ext];
}
return mediaType;
}
function publicPathExists(path: string): bool {
const full = normalize(PUBLIC_DIR + path);
return full.startsWith(PUBLIC_DIR) && existsSync(full);
}
export function handlePublicPath(
headers,
path: string
): Response {
path = normalize(path === '/' ? '/index.html' : path);
if(publicPathExists(path))
return new Response('', {
status: 200,
headers: {
...headers,
...{
'Content-Type': extractMediaType(path),
'X-Accel-Redirect': `/_public${path}`,
}
},
});
// 404 page
return new Response('<!doctype html><meta charset=utf-8><title>404 Not Found</title><h1>404 Not Found</h1>', {
status: 404,
headers: {
...headers,
...{ 'Content-Type': 'text/html;charset=utf-8' },
},
});
}

62
src/handlers/lookup.js Normal file
View file

@ -0,0 +1,62 @@
import { CACHE_LIFETIME, IS_DEBUG } from '../consts.js';
import { extractMetadata } from '../metadata.js';
const cache = new Map;
export async function handleMetadataLookup(req, res, urlStr, formatVersion=1) {
const started = performance.now();
for(const item in cache)
if(cache.get(item).at + CACHE_LIFETIME > Date.now())
cache.delete(item);
if(urlStr.startsWith('//'))
urlStr = 'https:' + urlStr;
let url;
try {
url = new URL(urlStr);
} catch(ex) {
res.status(400).json({error: 'metadata:uri'});
return;
}
urlStr = url.toString();
if(formatVersion < 1 || formatVersion > 2) {
res.status(400).json({error: 'metadata:version'});
return;
}
let cacheState;
let cacheMaxAge;
let metadata;
const cacheKey = `fv${formatVersion}:${urlStr}`;
const cacheInfo = cache.get(cacheKey);
if(typeof cacheInfo?.metadata === 'object') {
cacheState = 'cache';
cacheMaxAge = Math.max(0, CACHE_LIFETIME - Math.floor((Date.now() - cacheInfo.at) / 1000));
metadata = cacheInfo.metadata;
} else {
try {
metadata = await extractMetadata(formatVersion, url, urlStr);
} catch(ex) {
const info = {error: 'metadata:lookup'};
if(IS_DEBUG)
info._ex = ex.toString();
res.status(500).json(info);
return;
}
cacheState = 'fresh';
cacheMaxAge = CACHE_LIFETIME;
cache.set(cacheKey, { at: Date.now(), metadata });
}
res.set('Cache-Control', `max-age=${cacheMaxAge}, state-if-error=${CACHE_LIFETIME}, public, immutable`);
res.set('Server-Timing', `metadata;dur=${(performance.now() - started).toFixed(6)}`);
res.set('X-Uiharu-State', cacheState);
res.json(metadata);
};

View file

@ -1,107 +0,0 @@
import { APP_VERSION } from '../consts.ts';
import { readableStreamToString } from '../rs2str.ts';
import { extractMetadata } from '../metadata.ts';
import { encodeBase64Url } from "jsr:@std/encoding/base64url";
import { brotliCompressSync, brotliDecompressSync } from "node:zlib";
import { MemcacheClient } from 'npm:memcache-client@^1.0.5';
export async function handleMetadataLookup(
url: URL,
headers,
req: Request,
cache: MemcacheClient,
hostName: string
): Response {
if(!['GET', 'HEAD', 'POST'].includes(req.method))
return new Response('', { status: 405, headers });
const started = performance.now();
const urlParams = new URLSearchParams(url.search);
headers['Content-Type'] = 'application/json;charset=utf-8';
let urlParamRaw: String = '';
if(req.method === 'POST')
urlParamRaw = (await readableStreamToString(req.body)).trim();
else
urlParamRaw = urlParams.get('url')?.trim() ?? '';
if(urlParamRaw === '')
return new Response('{"error":"metadata:uri"}', { status: 400, headers });
if(urlParamRaw.startsWith('//'))
urlParamRaw = 'https:' + urlParamRaw;
let urlParam: URL;
try {
urlParam = new URL(urlParamRaw);
} catch(ex) {
return new Response('{"error":"metadata:uri"}', { status: 400, headers });
}
urlParamRaw = urlParam.toString();
const formatVersion = parseInt(urlParams.get('fv')) || 1;
if(formatVersion < 1 || formatVersion > 2)
return new Response('{"error":"metadata:version"}', { status: 400, headers });
const urlHash = encodeBase64Url(
await crypto.subtle.digest('SHA-256', new TextEncoder().encode(urlParamRaw))
);
const cacheKey = `uiharu:${APP_VERSION}:md:fv${formatVersion}:${urlHash}`;
const cacheInfo = await cache.get(cacheKey);
if(cacheInfo !== undefined)
return new Response(
brotliDecompressSync(cacheInfo.value),
{
status: 200,
headers: {
...headers,
...{
'Server-Timing': `metadata;dur=${(performance.now() - started).toFixed(6)}`,
'X-Uiharu-State': 'cache',
},
},
}
);
try {
const json = JSON.stringify(
await extractMetadata(formatVersion, hostName, urlParamRaw, urlParam)
);
cache.set(cacheKey, brotliCompressSync(json), {
compress: false,
lifetime: 600
});
return new Response(json, {
status: 200,
headers: {
...headers,
...{
'Server-Timing': `metadata;dur=${(performance.now() - started).toFixed(6)}`,
'X-Uiharu-State': 'fresh',
},
},
});
} catch(ex) {
console.error(ex);
return new Response('{"error":"metadata:lookup"}', { status: 500, headers });
}
};
export function handleMetadataBatchLookup(
headers,
req: Request
): Response {
if(!['GET', 'HEAD', 'POST'].includes(req.method))
return new Response('', { status: 405, headers });
return new Response('{"took":0,"results":[]}', {
headers: {
...headers,
...{ 'Content-Type': 'application/json' },
},
});
}

29
src/handlers/thumb.js Normal file
View file

@ -0,0 +1,29 @@
import { promisify } from 'node:util';
import child_process from 'node:child_process';
const exec = promisify(child_process.exec);
export async function handleThumbnailRetrieve(req, res, urlStr, audio) {
let url;
try {
url = new URL(urlStr);
} catch(ex) {
res.status(400).end();
return;
}
if(!['http:', 'https:'].includes(url.protocol))
return new Response('unsupported url scheme', { status: 400, headers });
const { error, stdout, stderr } = await exec(`ffmpeg -i ${urlStr} ${audio ? '-an' : ''} -f image2pipe -c:v ${audio ? 'copy' : 'png'} -frames:v 1 -`, { encoding: 'buffer', maxBuffer: 2 * 1024 * 1024 });
if(error && error.code !== 0) {
console.error(stderr);
res.status(500).end();
return;
}
res.set('Cache-Control', 'max-age=31536000, public, immutable');
res.set('Content-Type', 'image/png');
res.end(stdout);
}

View file

@ -1,64 +0,0 @@
export async function handleThumbnailRetrieve(
url: URL,
headers,
req: Request,
isAudio: bool,
isVideo: bool
): Response {
if(!['HEAD', 'GET'].includes(req.method))
return new Response('', { status: 405, headers });
let urlParamRaw: String = (new URLSearchParams(url.search)).get('url')?.trim() ?? '';
if(urlParamRaw === '')
return new Response('missing url parameter', { status: 400, headers });
let scheme: String = '';
try {
const urlParam = new URL(urlParamRaw);
if(typeof urlParam.protocol === 'string')
scheme = urlParam.protocol;
urlParamRaw = urlParam.toString();
} catch(ex) {
return new Response('invalid url parameter', { status: 400, headers });
}
if(!['http:', 'https:'].includes(scheme))
return new Response('unsupported url scheme', { status: 400, headers });
// this seems like a terrible idea lol
const args = ['-i', urlParamRaw];
if(isAudio) args.push('-an');
args.push('-f');
args.push('image2pipe');
args.push('-c:v');
args.push(isVideo ? 'png' : 'copy');
args.push('-frames:v');
args.push('1');
args.push('-');
const { code, stdout, stderr } = await (new Deno.Command('ffmpeg', {
stdin: 'null',
stdout: 'piped',
stderr: 'piped',
args,
})).output();
if(code !== 0) {
console.error(new TextDecoder().decode(stderr));
return new Response('decode failed', { status: 500, headers });
}
// TODO: bother with cache someday maybe
const thumb = stdout;
return new Response(thumb, {
headers: {
...headers,
...{
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=31536000, immutable',
},
},
});
}

View file

@ -1,24 +1,40 @@
import { IS_DEBUG } from './consts.ts';
import { fetchWithHeaders } from './fetch.ts';
import { readableStreamToString } from './rs2str.ts';
import { extractOEmbedData, isAllowedOEmbedDomain } from './metadata/oembed.ts';
import { extractOpenGraphData } from './metadata/og.ts';
import { extractHtmlMetaData } from './metadata/html.ts';
import { extractTwitterData } from './metadata/twitter.ts';
import { extractLinkedData } from './metadata/ld.ts';
import * as cheerio from 'npm:cheerio@^1.0.0';
import { basename } from "jsr:@std/path";
import { parseMediaType } from "jsr:@std/media-types";
import { HOSTNAME, IS_DEBUG } from './consts.js';
import { fetchWithHeaders } from './fetch.js';
import { readableStreamToString } from './rs2str.js';
import { extractOEmbedData, isAllowedOEmbedDomain } from './metadata/oembed.js';
import { extractOpenGraphData } from './metadata/og.js';
import { extractHtmlMetaData } from './metadata/html.js';
import { extractTwitterData } from './metadata/twitter.js';
import { extractLinkedData } from './metadata/ld.js';
import { basename } from 'node:path';
import { promisify } from 'node:util';
import child_process from 'node:child_process';
import * as cheerio from 'cheerio';
import { parse as parseContentDisposition } from 'content-disposition';
import MIMEType from 'whatwg-mimetype';
const exec = promisify(child_process.exec);
export async function extractMetadata(version, url, urlStr) {
const response = await fetchWithHeaders(urlStr);
const contentDispositionRaw = response.headers.get('content-disposition') ?? '';
const contentDisposition = (() => {
try {
return parseContentDisposition(contentDispositionRaw);
} catch(ex) {
return null;
}
})();
export async function extractMetadata(
version: number,
hostName: string,
url: string,
urlInfo: URL
) {
const response = await fetchWithHeaders(url);
const contentTypeRaw = response.headers.get('content-type') ?? '';
const contentType = parseMediaType(contentTypeRaw);
const contentType = (() => {
try {
return new MIMEType(contentTypeRaw);
} catch(ex) {
return new MIMEType('application/octet-stream');
}
})();
const info = {};
const addInfoOrDont = (prop, value) => {
@ -26,16 +42,16 @@ export async function extractMetadata(
info[prop] = value;
};
info.url = url;
info.title = decodeURIComponent(basename(urlInfo.pathname));
info.site_name = urlInfo.host;
info.url = urlStr;
info.title = contentDisposition?.parameters?.filename ?? decodeURIComponent(basename(url.pathname));
info.site_name = url.host;
if(contentType[0])
info.media_type = contentType[0];
if(contentType.essence)
info.media_type = contentType.essence;
let html = undefined;
let html;
if(['text/html', 'application/xhtml+xml'].includes(contentType[0])) {
if(['text/html', 'application/xhtml+xml'].includes(contentType.essence)) {
html = cheerio.load(await readableStreamToString(response.body));
const metaData = extractHtmlMetaData(html);
@ -105,29 +121,17 @@ export async function extractMetadata(
if(IS_DEBUG && linkedDatas.length > 0)
info._lds = linkedDatas;
} else {
const isAudio = contentType[0].startsWith('audio/');
const isImage = contentType[0].startsWith('image/');
const isVideo = contentType[0].startsWith('video/');
const isAudio = contentType.type === 'audio';
const isImage = contentType.type === 'image';
const isVideo = contentType.type === 'video';
if(isAudio || isImage || isVideo) {
// this still seems like a terrible idea lol
const { code, stdout, stderr } = await (new Deno.Command('ffprobe', {
stdin: 'null',
stdout: 'piped',
stderr: 'piped',
args: [
'-show_streams',
'-show_format',
'-print_format', 'json',
'-v', 'quiet',
'-i', url
],
})).output();
const { error, stdout, stderr } = await exec(`ffprobe -show_streams -show_format -print_format json -v quiet -i ${urlStr}`);
if(code !== 0) {
console.error(new TextDecoder().decode(stderr));
if(error && error.code !== 0) {
console.error(stderr);
} else {
const probe = JSON.parse(new TextDecoder().decode(stdout).trim());
const probe = JSON.parse(stdout);
if(IS_DEBUG)
info._ffprobe = probe;
@ -200,8 +204,8 @@ export async function extractMetadata(
}
if(isAudio) {
info.audio_url = url;
info.image_url = `${version < 2 ? '' : 'https:'}//${hostName}/metadata/thumb/audio?url=${encodeURIComponent(url)}`;
info.audio_url = urlStr;
info.image_url = `${version < 2 ? '' : 'https:'}//${HOSTNAME}/metadata/thumb/audio?url=${encodeURIComponent(urlStr)}`;
info.image_type = 'image/png';
let title = '';
@ -218,7 +222,7 @@ export async function extractMetadata(
if(typeof info.media.tags.comment === 'string')
info.description = info.media.tags.comment.trim();
} else if(isImage) {
info.image_url = url;
info.image_url = urlStr;
info.image_type = info.media_type;
if(info.media.width > 0)
@ -226,8 +230,8 @@ export async function extractMetadata(
if(info.media.height > 0)
info.height = info.image_height = info.media.height;
} else if(isVideo) {
info.video_url = url;
info.image_url = `${version < 2 ? '' : 'https:'}//${hostName}/metadata/thumb/video?url=${encodeURIComponent(url)}`;
info.video_url = urlStr;
info.image_url = `${version < 2 ? '' : 'https:'}//${HOSTNAME}/metadata/thumb/video?url=${encodeURIComponent(urlStr)}`;
info.image_type = 'image/png';
if(info.media.width > 0)
@ -249,8 +253,8 @@ export async function extractMetadata(
}
}
if(isAllowedOEmbedDomain(urlInfo.host)) {
const oEmbedData = await extractOEmbedData(response, html, url, urlInfo);
if(isAllowedOEmbedDomain(url.host)) {
const oEmbedData = await extractOEmbedData(response, html, url, urlStr);
if(oEmbedData.version)
info.oembed = oEmbedData;
}

View file

@ -1,4 +1,4 @@
import { Color } from "https://deno.land/x/color@v0.3.0/mod.ts";
import Color from 'color';
export function extractHtmlMetaData(html) {
const values = {};
@ -17,7 +17,9 @@ export function extractHtmlMetaData(html) {
const metaThemeColorTag = html('meta[name="theme-color"]').first()?.attr('content')?.trim() ?? '';
if(metaThemeColorTag.length > 0)
values.theme_color = Color.string(metaThemeColorTag).hex();
try {
values.theme_color = Color(metaThemeColorTag).hex();
} catch(ex) {}
const linkImageSrcTag = html('link[rel="image_src"]').first()?.attr('href')?.trim() ?? '';
if(linkImageSrcTag.length > 0)

View file

@ -1,9 +1,9 @@
import { fetchWithHeaders } from '../fetch.ts';
import { fetchWithHeaders } from '../fetch.js';
// copied from wordpress source sorta
// i was going to make this a config setting but some services dont provide an alternate url
// so it will become more involved in the future
const allowOEmbed: String[] = [
const allowOEmbed = [
'.youtube.com',
'.youtu.be',
'.vimeo.com',
@ -79,7 +79,7 @@ const allowOEmbed: String[] = [
'.edgii.net',
];
function isDomainSuffix(known: string, user: string) {
function isDomainSuffix(known, user) {
if(!known.startsWith('.'))
known = '.' + known;
if(!user.startsWith('.'))
@ -88,7 +88,7 @@ function isDomainSuffix(known: string, user: string) {
return user.endsWith(known);
};
function parseLinkHeader(header: string) {
function parseLinkHeader(header) {
const links = [];
const lines = header.split(',');
@ -119,7 +119,10 @@ function parseLinkHeader(header: string) {
return links;
};
export function isAllowedOEmbedDomain(domain: string): Boolean {
export function isAllowedOEmbedDomain(domain) {
if(typeof domain !== 'string')
return false;
if(!domain.startsWith('.'))
domain = '.' + domain;
@ -130,18 +133,18 @@ export function isAllowedOEmbedDomain(domain: string): Boolean {
return false;
};
export async function extractOEmbedData(response: Response, html, url: string, urlInfo: URL) {
let oEmbedUrl: string = '';
export async function extractOEmbedData(response, html, url, urlStr) {
let oEmbedUrl = '';
// TODO: maintain a fucking list because its too difficult for services to just provide <link rel=alternate> tags
if(isDomainSuffix('x.com', urlInfo.host) || isDomainSuffix('twitter.com', urlInfo.host))
oEmbedUrl = `https://publish.twitter.com/oembed?dnt=true&omit_script=true&url=${encodeURIComponent(url)}`;
else if(isDomainSuffix('soundcloud.com', urlInfo.host))
oEmbedUrl = `https://soundcloud.com/oembed?format=json&url=${encodeURIComponent(url)}`;
else if(isDomainSuffix('tiktok.com', urlInfo.host))
oEmbedUrl = `https://www.tiktok.com/oembed?url=${encodeURIComponent(url)}`;
else if(isDomainSuffix('mixcloud.com', urlInfo.host))
oEmbedUrl = `https://app.mixcloud.com/oembed/?url=${encodeURIComponent(url)}`;
if(isDomainSuffix('x.com', url.host) || isDomainSuffix('twitter.com', url.host))
oEmbedUrl = `https://publish.twitter.com/oembed?dnt=true&omit_script=true&url=${encodeURIComponent(urlStr)}`;
else if(isDomainSuffix('soundcloud.com', url.host))
oEmbedUrl = `https://soundcloud.com/oembed?format=json&url=${encodeURIComponent(urlStr)}`;
else if(isDomainSuffix('tiktok.com', url.host))
oEmbedUrl = `https://www.tiktok.com/oembed?url=${encodeURIComponent(urlStr)}`;
else if(isDomainSuffix('mixcloud.com', url.host))
oEmbedUrl = `https://app.mixcloud.com/oembed/?url=${encodeURIComponent(urlStr)}`;
else if(html !== undefined)
oEmbedUrl = html('link[rel="alternate"][type="application/json+oembed"]').first()?.attr('href')?.trim() ?? '';

View file

@ -90,7 +90,7 @@ export function extractOpenGraphData(html) {
else {
if(propInfo.type === 'mime') {
// world's most naive validation
if(value.indexOf('/') < 0)
if(!value.includes('/'))
value = undefined;
} else if(propInfo.type === 'url') {
try {

View file

@ -1,5 +1,5 @@
export async function readableStreamToString(stream?: ReadableStream): string {
if(stream === null)
export async function readableStreamToString(stream) {
if(!(stream instanceof ReadableStream))
return '';
const reader = stream.getReader();

68
uiharu.js Normal file
View file

@ -0,0 +1,68 @@
import { ALLOWED_ORIGINS, PORT, VERSION } from './src/consts.js';
import { accessControlHandler } from './src/cors.js';
import { handleThumbnailRetrieve } from './src/handlers/thumb.js';
import { handleMetadataLookup } from './src/handlers/lookup.js';
import express from 'express';
const app = express();
app.set('case sensitive routing', true); // seems like the right thing to do
app.set('trust proxy', true); // always run this app behind NGINX
app.set('x-powered-by', false); // nuh uh we have our own
// MIDDLEWARES
app.use(express.static('public'));
app.use((req, res, next) => {
res.set('X-Powered-By', `Uiharu/${VERSION}`);
next();
});
app.use(accessControlHandler({
'/metadata': { methods: { 'GET': {}, 'POST': {} } },
'/metadata/batch': { methods: { 'GET': {}, 'POST': {} } },
'/metadata/thumb/audio': { methods: { 'GET': {} } },
'/metadata/thumb/video': { methods: { 'GET': {} } },
}, ALLOWED_ORIGINS));
// metadata lookup
app.get('/metadata', async (req, res) => {
if(typeof req.query !== 'object' || typeof req.query.url !== 'string' || req.query.url === '') {
res.status(400).end();
return;
}
await handleMetadataLookup(req, res, req.query.url);
});
app.post('/metadata', async (req, res) => {
let body = '';
req.setEncoding('ascii');
req.on('data', chunk => { body += chunk; });
req.on('end', () => {
if(body === '')
res.status(400).end();
else
handleMetadataLookup(req, res, body);
});
});
// batch lookups are no longer supported
app.get('/metadata/batch', (req, res) => { res.status(410).end(); });
app.post('/metadata/batch', (req, res) => { res.status(410).end(); });
app.get('/metadata/thumb/audio', async (req, res) => {
if(typeof req.query !== 'object' || typeof req.query.url !== 'string' || req.query.url === '') {
res.status(400).end();
return;
}
await handleThumbnailRetrieve(req, res, req.query.url, true);
});
app.get('/metadata/thumb/video', async (req, res) => {
if(typeof req.query !== 'object' || typeof req.query.url !== 'string' || req.query.url === '') {
res.status(400).end();
return;
}
await handleThumbnailRetrieve(req, res, req.query.url, false);
});
console.info(`Starting Uiharu ${VERSION} on :${PORT}`);
app.listen(PORT);

View file

@ -1,69 +0,0 @@
import { handleMetadataLookup, handleMetadataBatchLookup } from './src/handlers/lookup.ts';
import { handleThumbnailRetrieve } from './src/handlers/thumb.ts';
import { handlePublicPath } from './src/handlers/local.ts';
import { MemcacheClient } from 'npm:memcache-client@^1.0.5';
// todo: these should not be hardcoded lol
const hostName: String = 'uiharu.edgii.net';
const port: Number = 3009;
const memcacheServer: String = '127.0.0.1:11211';
const allowedOrigins: String[] = [
'edgii.net',
'chat.edgii.net',
'sockchat.edgii.net',
'ajaxchat.edgii.net',
];
const cache: MemcacheClient = new MemcacheClient({
server: memcacheServer,
compressor: {
// fuck it lol
compressSync: buffer => buffer,
decompressSync: buffer => buffer,
},
});
Deno.serve({ port }, async (req: Request): Response => {
const url = new URL(req.url);
const headers = { 'X-Powered-By': 'Uiharu' };
if(req.headers.has('origin')) {
const originRaw = req.headers.get('origin');
const origin = new URL(originRaw);
if(!allowedOrigins.includes(origin.host))
return new Response('403', { status: 403, headers });
headers['Access-Control-Allow-Origin'] = originRaw;
headers['Vary'] = 'Origin';
}
if(req.method === 'OPTIONS') {
headers['Allow'] = 'OPTIONS, GET, HEAD, POST';
headers['Access-Control-Allow-Methods'] = 'OPTIONS, GET, HEAD, POST';
// idk if this is the appropriate status code but: balls
return new Response('', { status: 204, headers });
}
if(url.pathname === '/metadata')
return handleMetadataLookup(url, headers, req, cache, hostName);
if(url.pathname === '/metadata/batch')
return handleMetadataBatchLookup(headers, req);
const isAudio = url.pathname === '/metadata/thumb/audio';
const isVideo = url.pathname === '/metadata/thumb/video';
if(isAudio || isVideo)
return handleThumbnailRetrieve(url, headers, req, isAudio, isVideo);
// serving files from /public dir
if(['HEAD', 'GET'].includes(req.method))
return handlePublicPath(headers, url.pathname);
// 404 fallback
return new Response('', {
status: ['OPTIONS', 'HEAD', 'GET', 'POST'].includes(req.method) ? 404 : 405,
headers,
});
});