Deno -> NodeJS
This commit is contained in:
parent
9cd1d8e489
commit
fe2204650a
24 changed files with 1456 additions and 413 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -8,3 +8,4 @@
|
|||
/lib/index-dev
|
||||
/vendor
|
||||
/node_modules
|
||||
/.env
|
||||
|
|
2
LICENCE
2
LICENCE
|
@ -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
28
deno.lock
generated
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
1129
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
13
package.json
Normal file
13
package.json
Normal 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
9
src/consts.js
Normal 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');
|
|
@ -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
61
src/cors.js
Normal 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();
|
||||
};
|
||||
};
|
|
@ -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);
|
||||
};
|
|
@ -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
62
src/handlers/lookup.js
Normal 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);
|
||||
};
|
|
@ -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
29
src/handlers/thumb.js
Normal 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);
|
||||
}
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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)
|
|
@ -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() ?? '';
|
||||
|
|
@ -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 {
|
|
@ -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
68
uiharu.js
Normal 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);
|
69
uiharu.ts
69
uiharu.ts
|
@ -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,
|
||||
});
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue