Make it public!
This commit is contained in:
238
lib/auth.ts
Normal file
238
lib/auth.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { decodeBase64Url, encodeBase64Url } from 'std/encoding/base64url.ts';
|
||||
import { decodeBase64 } from 'std/encoding/base64.ts';
|
||||
import { Cookie, getCookies, setCookie } from 'std/http/cookie.ts';
|
||||
import 'std/dotenv/load.ts';
|
||||
|
||||
import { baseUrl, generateHash, isRunningLocally } from './utils.ts';
|
||||
import { User, UserSession } from './types.ts';
|
||||
import { createUserSession, deleteUserSession, getUserByEmail, validateUserAndSession } from './data/user.ts';
|
||||
|
||||
const JWT_SECRET = Deno.env.get('JWT_SECRET') || '';
|
||||
export const PASSWORD_SALT = Deno.env.get('PASSWORD_SALT') || '';
|
||||
export const COOKIE_NAME = 'bewcloud-app-v1';
|
||||
|
||||
export interface JwtData {
|
||||
data: {
|
||||
user_id: string;
|
||||
session_id: string;
|
||||
};
|
||||
}
|
||||
|
||||
const textToData = (text: string) => new TextEncoder().encode(text);
|
||||
|
||||
export const dataToText = (data: Uint8Array) => new TextDecoder().decode(data);
|
||||
|
||||
const generateKey = async (key: string) =>
|
||||
await crypto.subtle.importKey('raw', textToData(key), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
|
||||
|
||||
async function signAuthJwt(key: CryptoKey, data: JwtData) {
|
||||
const payload = encodeBase64Url(textToData(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))) + '.' +
|
||||
encodeBase64Url(textToData(JSON.stringify(data) || ''));
|
||||
const signature = encodeBase64Url(
|
||||
new Uint8Array(await crypto.subtle.sign({ name: 'HMAC' }, key, textToData(payload))),
|
||||
);
|
||||
return `${payload}.${signature}`;
|
||||
}
|
||||
|
||||
async function verifyAuthJwt(key: CryptoKey, jwt: string) {
|
||||
const jwtParts = jwt.split('.');
|
||||
if (jwtParts.length !== 3) {
|
||||
throw new Error('Malformed JWT');
|
||||
}
|
||||
|
||||
const data = textToData(jwtParts[0] + '.' + jwtParts[1]);
|
||||
if (await crypto.subtle.verify({ name: 'HMAC' }, key, decodeBase64Url(jwtParts[2]), data) === true) {
|
||||
return JSON.parse(dataToText(decodeBase64Url(jwtParts[1]))) as JwtData;
|
||||
}
|
||||
|
||||
throw new Error('Invalid JWT');
|
||||
}
|
||||
|
||||
export async function getDataFromRequest(request: Request) {
|
||||
const cookies = getCookies(request.headers);
|
||||
const authorizationHeader = request.headers.get('authorization');
|
||||
|
||||
if (cookies[COOKIE_NAME]) {
|
||||
const result = await getDataFromCookie(cookies[COOKIE_NAME]);
|
||||
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if (authorizationHeader) {
|
||||
const result = await getDataFromAuthorizationHeader(authorizationHeader);
|
||||
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getDataFromAuthorizationHeader(authorizationHeader: string) {
|
||||
if (!authorizationHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only basic auth is supported for now
|
||||
if (!authorizationHeader.startsWith('Basic ')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const basicAuthHash = authorizationHeader.split('Basic ')[1] || '';
|
||||
|
||||
const [basicAuthUsername, basicAuthPassword] = dataToText(decodeBase64(basicAuthHash)).split(':');
|
||||
|
||||
const hashedPassword = await generateHash(`${basicAuthPassword}:${PASSWORD_SALT}`, 'SHA-256');
|
||||
|
||||
const user = await getUserByEmail(basicAuthUsername);
|
||||
|
||||
if (!user || (user.hashed_password !== hashedPassword && user.extra.dav_hashed_password !== hashedPassword)) {
|
||||
throw new Error('Email not found or invalid password.');
|
||||
}
|
||||
|
||||
return { user, session: undefined };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getDataFromCookie(cookieValue: string) {
|
||||
if (!cookieValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = await generateKey(JWT_SECRET);
|
||||
|
||||
try {
|
||||
const token = await verifyAuthJwt(key, cookieValue) as JwtData;
|
||||
|
||||
const { user, session } = await validateUserAndSession(token.data.user_id, token.data.session_id);
|
||||
|
||||
return { user, session, tokenData: token.data };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function generateToken(tokenData: JwtData['data']) {
|
||||
const key = await generateKey(JWT_SECRET);
|
||||
|
||||
const token = await signAuthJwt(key, { data: tokenData });
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function logoutUser(request: Request) {
|
||||
const tomorrow = new Date(new Date().setDate(new Date().getDate() + 1));
|
||||
|
||||
const cookies = getCookies(request.headers);
|
||||
|
||||
const result = await getDataFromCookie(cookies[COOKIE_NAME]);
|
||||
|
||||
if (!result || !result.tokenData?.session_id || !result.user) {
|
||||
throw new Error('Invalid session');
|
||||
}
|
||||
|
||||
const { tokenData } = result;
|
||||
const { session_id } = tokenData;
|
||||
|
||||
// Delete user session
|
||||
await deleteUserSession(session_id);
|
||||
|
||||
// Generate response with empty and expiring cookie
|
||||
const cookie: Cookie = {
|
||||
name: COOKIE_NAME,
|
||||
value: '',
|
||||
expires: tomorrow,
|
||||
domain: isRunningLocally(request) ? 'localhost' : baseUrl.replace('https://', ''),
|
||||
path: '/',
|
||||
secure: isRunningLocally(request) ? false : true,
|
||||
httpOnly: true,
|
||||
sameSite: 'Lax',
|
||||
};
|
||||
|
||||
const response = new Response('Logged Out', {
|
||||
status: 303,
|
||||
headers: { 'Location': '/', 'Content-Type': 'text/html; charset=utf-8' },
|
||||
});
|
||||
|
||||
setCookie(response.headers, cookie);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function createSessionResponse(
|
||||
request: Request,
|
||||
user: User,
|
||||
{ urlToRedirectTo = '/' }: {
|
||||
urlToRedirectTo?: string;
|
||||
} = {},
|
||||
) {
|
||||
const response = new Response('Logged In', {
|
||||
status: 303,
|
||||
headers: { 'Location': urlToRedirectTo, 'Content-Type': 'text/html; charset=utf-8' },
|
||||
});
|
||||
|
||||
const responseWithCookie = await createSessionCookie(request, user, response);
|
||||
|
||||
return responseWithCookie;
|
||||
}
|
||||
|
||||
export async function createSessionCookie(
|
||||
request: Request,
|
||||
user: User,
|
||||
response: Response,
|
||||
isShortLived = false,
|
||||
) {
|
||||
const newSession = await createUserSession(user, isShortLived);
|
||||
|
||||
// Generate response with session cookie
|
||||
const token = await generateToken({ user_id: user.id, session_id: newSession.id });
|
||||
|
||||
const cookie: Cookie = {
|
||||
name: COOKIE_NAME,
|
||||
value: token,
|
||||
expires: newSession.expires_at,
|
||||
domain: isRunningLocally(request) ? 'localhost' : baseUrl.replace('https://', ''),
|
||||
path: '/',
|
||||
secure: isRunningLocally(request) ? false : true,
|
||||
httpOnly: true,
|
||||
sameSite: 'Lax',
|
||||
};
|
||||
|
||||
setCookie(response.headers, cookie);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function updateSessionCookie(
|
||||
response: Response,
|
||||
request: Request,
|
||||
userSession: UserSession,
|
||||
newSessionData: JwtData['data'],
|
||||
) {
|
||||
const token = await generateToken(newSessionData);
|
||||
|
||||
const cookie: Cookie = {
|
||||
name: COOKIE_NAME,
|
||||
value: token,
|
||||
expires: userSession.expires_at,
|
||||
domain: isRunningLocally(request) ? 'localhost' : baseUrl.replace('https://', ''),
|
||||
path: '/',
|
||||
secure: isRunningLocally(request) ? false : true,
|
||||
httpOnly: true,
|
||||
sameSite: 'Lax',
|
||||
};
|
||||
|
||||
setCookie(response.headers, cookie);
|
||||
|
||||
return response;
|
||||
}
|
||||
15
lib/config.ts
Normal file
15
lib/config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'std/dotenv/load.ts';
|
||||
|
||||
import { isThereAnAdmin } from './data/user.ts';
|
||||
|
||||
export async function isSignupAllowed() {
|
||||
const areSignupsAllowed = Deno.env.get('CONFIG_ALLOW_SIGNUPS') === 'true';
|
||||
|
||||
const areThereAdmins = await isThereAnAdmin();
|
||||
|
||||
if (areSignupsAllowed || !areThereAdmins) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
138
lib/data/contacts.ts
Normal file
138
lib/data/contacts.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||
import { Contact } from '/lib/types.ts';
|
||||
import { CONTACTS_PER_PAGE_COUNT } from '/lib/utils.ts';
|
||||
import { updateUserContactRevision } from './user.ts';
|
||||
|
||||
const db = new Database();
|
||||
|
||||
export async function getContacts(userId: string, pageIndex: number) {
|
||||
const contacts = await db.query<Pick<Contact, 'id' | 'first_name' | 'last_name'>>(
|
||||
sql`SELECT "id", "first_name", "last_name" FROM "bewcloud_contacts" WHERE "user_id" = $1 ORDER BY "first_name" ASC, "last_name" ASC LIMIT ${CONTACTS_PER_PAGE_COUNT} OFFSET $2`,
|
||||
[
|
||||
userId,
|
||||
pageIndex * CONTACTS_PER_PAGE_COUNT,
|
||||
],
|
||||
);
|
||||
|
||||
return contacts;
|
||||
}
|
||||
|
||||
export async function getContactsCount(userId: string) {
|
||||
const results = await db.query<{ count: number }>(
|
||||
sql`SELECT COUNT("id") AS "count" FROM "bewcloud_contacts" WHERE "user_id" = $1`,
|
||||
[
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
return Number(results[0]?.count || 0);
|
||||
}
|
||||
|
||||
export async function searchContacts(search: string, userId: string, pageIndex: number) {
|
||||
const contacts = await db.query<Pick<Contact, 'id' | 'first_name' | 'last_name'>>(
|
||||
sql`SELECT "id", "first_name", "last_name" FROM "bewcloud_contacts" WHERE "user_id" = $1 AND ("first_name" ILIKE $3 OR "last_name" ILIKE $3 OR "extra"::text ILIKE $3) ORDER BY "first_name" ASC, "last_name" ASC LIMIT ${CONTACTS_PER_PAGE_COUNT} OFFSET $2`,
|
||||
[
|
||||
userId,
|
||||
pageIndex * CONTACTS_PER_PAGE_COUNT,
|
||||
`%${search}%`,
|
||||
],
|
||||
);
|
||||
|
||||
return contacts;
|
||||
}
|
||||
|
||||
export async function searchContactsCount(search: string, userId: string) {
|
||||
const results = await db.query<{ count: number }>(
|
||||
sql`SELECT COUNT("id") AS "count" FROM "bewcloud_contacts" WHERE "user_id" = $1 AND ("first_name" ILIKE $2 OR "last_name" ILIKE $2 OR "extra"::text ILIKE $2)`,
|
||||
[
|
||||
userId,
|
||||
`%${search}%`,
|
||||
],
|
||||
);
|
||||
|
||||
return Number(results[0]?.count || 0);
|
||||
}
|
||||
|
||||
export async function getAllContacts(userId: string) {
|
||||
const contacts = await db.query<Contact>(sql`SELECT * FROM "bewcloud_contacts" WHERE "user_id" = $1`, [
|
||||
userId,
|
||||
]);
|
||||
|
||||
return contacts;
|
||||
}
|
||||
|
||||
export async function getContact(id: string, userId: string) {
|
||||
const contacts = await db.query<Contact>(
|
||||
sql`SELECT * FROM "bewcloud_contacts" WHERE "id" = $1 AND "user_id" = $2 LIMIT 1`,
|
||||
[
|
||||
id,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
return contacts[0];
|
||||
}
|
||||
|
||||
export async function createContact(userId: string, firstName: string, lastName: string) {
|
||||
const extra: Contact['extra'] = {};
|
||||
|
||||
const revision = crypto.randomUUID();
|
||||
|
||||
const newContact = (await db.query<Contact>(
|
||||
sql`INSERT INTO "bewcloud_contacts" (
|
||||
"user_id",
|
||||
"revision",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"extra"
|
||||
) VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[
|
||||
userId,
|
||||
revision,
|
||||
firstName,
|
||||
lastName,
|
||||
JSON.stringify(extra),
|
||||
],
|
||||
))[0];
|
||||
|
||||
await updateUserContactRevision(userId);
|
||||
|
||||
return newContact;
|
||||
}
|
||||
|
||||
export async function updateContact(contact: Contact) {
|
||||
const revision = crypto.randomUUID();
|
||||
|
||||
await db.query(
|
||||
sql`UPDATE "bewcloud_contacts" SET
|
||||
"revision" = $3,
|
||||
"first_name" = $4,
|
||||
"last_name" = $5,
|
||||
"extra" = $6,
|
||||
"updated_at" = now()
|
||||
WHERE "id" = $1 AND "revision" = $2`,
|
||||
[
|
||||
contact.id,
|
||||
contact.revision,
|
||||
revision,
|
||||
contact.first_name,
|
||||
contact.last_name,
|
||||
JSON.stringify(contact.extra),
|
||||
],
|
||||
);
|
||||
|
||||
await updateUserContactRevision(contact.user_id);
|
||||
}
|
||||
|
||||
export async function deleteContact(id: string, userId: string) {
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_contacts" WHERE "id" = $1 AND "user_id" = $2`,
|
||||
[
|
||||
id,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
await updateUserContactRevision(userId);
|
||||
}
|
||||
42
lib/data/dashboard.ts
Normal file
42
lib/data/dashboard.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||
import { Dashboard } from '/lib/types.ts';
|
||||
|
||||
const db = new Database();
|
||||
|
||||
export async function getDashboardByUserId(userId: string) {
|
||||
const dashboard = (await db.query<Dashboard>(sql`SELECT * FROM "bewcloud_dashboards" WHERE "user_id" = $1 LIMIT 1`, [
|
||||
userId,
|
||||
]))[0];
|
||||
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
export async function createDashboard(userId: string) {
|
||||
const data: Dashboard['data'] = { links: [], notes: '' };
|
||||
|
||||
const newDashboard = (await db.query<Dashboard>(
|
||||
sql`INSERT INTO "bewcloud_dashboards" (
|
||||
"user_id",
|
||||
"data"
|
||||
) VALUES ($1, $2)
|
||||
RETURNING *`,
|
||||
[
|
||||
userId,
|
||||
JSON.stringify(data),
|
||||
],
|
||||
))[0];
|
||||
|
||||
return newDashboard;
|
||||
}
|
||||
|
||||
export async function updateDashboard(dashboard: Dashboard) {
|
||||
await db.query(
|
||||
sql`UPDATE "bewcloud_dashboards" SET
|
||||
"data" = $2
|
||||
WHERE "id" = $1`,
|
||||
[
|
||||
dashboard.id,
|
||||
JSON.stringify(dashboard.data),
|
||||
],
|
||||
);
|
||||
}
|
||||
298
lib/data/news.ts
Normal file
298
lib/data/news.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import { Feed } from 'https://deno.land/x/rss@1.0.0/mod.ts';
|
||||
|
||||
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||
import { NewsFeed, NewsFeedArticle } from '/lib/types.ts';
|
||||
import {
|
||||
findFeedInUrl,
|
||||
getArticleUrl,
|
||||
getFeedInfo,
|
||||
JsonFeed,
|
||||
parseTextFromHtml,
|
||||
parseUrl,
|
||||
parseUrlAsGooglebot,
|
||||
parseUrlWithProxy,
|
||||
} from '/lib/feed.ts';
|
||||
|
||||
const db = new Database();
|
||||
|
||||
export async function getNewsFeeds(userId: string) {
|
||||
const newsFeeds = await db.query<NewsFeed>(sql`SELECT * FROM "bewcloud_news_feeds" WHERE "user_id" = $1`, [
|
||||
userId,
|
||||
]);
|
||||
|
||||
return newsFeeds;
|
||||
}
|
||||
|
||||
export async function getNewsFeed(id: string, userId: string) {
|
||||
const newsFeeds = await db.query<NewsFeed>(
|
||||
sql`SELECT * FROM "bewcloud_news_feeds" WHERE "id" = $1 AND "user_id" = $2 LIMIT 1`,
|
||||
[
|
||||
id,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
return newsFeeds[0];
|
||||
}
|
||||
|
||||
export async function getNewsArticles(userId: string) {
|
||||
const articles = await db.query<NewsFeedArticle>(
|
||||
sql`SELECT * FROM "bewcloud_news_feed_articles" WHERE "user_id" = $1 ORDER BY "article_date" DESC`,
|
||||
[
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
return articles;
|
||||
}
|
||||
|
||||
export async function getNewsArticlesByFeedId(feedId: string) {
|
||||
const articles = await db.query<NewsFeedArticle>(
|
||||
sql`SELECT * FROM "bewcloud_news_feed_articles" WHERE "feed_id" = $1 ORDER BY "article_date" DESC`,
|
||||
[
|
||||
feedId,
|
||||
],
|
||||
);
|
||||
|
||||
return articles;
|
||||
}
|
||||
|
||||
export async function getNewsArticle(id: string, userId: string) {
|
||||
const articles = await db.query<NewsFeedArticle>(
|
||||
sql`SELECT * FROM "bewcloud_news_feed_articles" WHERE "id" = $1 AND "user_id" = $2 LIMIT 1`,
|
||||
[
|
||||
id,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
return articles[0];
|
||||
}
|
||||
|
||||
export async function createNewsFeed(userId: string, feedUrl: string) {
|
||||
const extra: NewsFeed['extra'] = {};
|
||||
|
||||
const newNewsFeed = (await db.query<NewsFeed>(
|
||||
sql`INSERT INTO "bewcloud_news_feeds" (
|
||||
"user_id",
|
||||
"feed_url",
|
||||
"extra"
|
||||
) VALUES ($1, $2, $3)
|
||||
RETURNING *`,
|
||||
[
|
||||
userId,
|
||||
feedUrl,
|
||||
JSON.stringify(extra),
|
||||
],
|
||||
))[0];
|
||||
|
||||
return newNewsFeed;
|
||||
}
|
||||
|
||||
export async function updateNewsFeed(newsFeed: NewsFeed) {
|
||||
await db.query(
|
||||
sql`UPDATE "bewcloud_news_feeds" SET
|
||||
"feed_url" = $2,
|
||||
"last_crawled_at" = $3,
|
||||
"extra" = $4
|
||||
WHERE "id" = $1`,
|
||||
[
|
||||
newsFeed.id,
|
||||
newsFeed.feed_url,
|
||||
newsFeed.last_crawled_at,
|
||||
JSON.stringify(newsFeed.extra),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteNewsFeed(id: string) {
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_news_feed_articles" WHERE "feed_id" = $1`,
|
||||
[
|
||||
id,
|
||||
],
|
||||
);
|
||||
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_news_feeds" WHERE "id" = $1`,
|
||||
[
|
||||
id,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function createsNewsArticle(
|
||||
userId: string,
|
||||
feedId: string,
|
||||
article: Omit<NewsFeedArticle, 'id' | 'user_id' | 'feed_id' | 'extra' | 'is_read' | 'created_at'>,
|
||||
) {
|
||||
const extra: NewsFeedArticle['extra'] = {};
|
||||
|
||||
const newNewsArticle = (await db.query<NewsFeedArticle>(
|
||||
sql`INSERT INTO "bewcloud_news_feed_articles" (
|
||||
"user_id",
|
||||
"feed_id",
|
||||
"article_url",
|
||||
"article_title",
|
||||
"article_summary",
|
||||
"article_date",
|
||||
"extra"
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *`,
|
||||
[
|
||||
userId,
|
||||
feedId,
|
||||
article.article_url,
|
||||
article.article_title,
|
||||
article.article_summary,
|
||||
article.article_date,
|
||||
JSON.stringify(extra),
|
||||
],
|
||||
))[0];
|
||||
|
||||
return newNewsArticle;
|
||||
}
|
||||
|
||||
export async function updateNewsArticle(article: NewsFeedArticle) {
|
||||
await db.query(
|
||||
sql`UPDATE "bewcloud_news_feed_articles" SET
|
||||
"is_read" = $2,
|
||||
"extra" = $3
|
||||
WHERE "id" = $1`,
|
||||
[
|
||||
article.id,
|
||||
article.is_read,
|
||||
JSON.stringify(article.extra),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function markAllArticlesRead(userId: string) {
|
||||
await db.query(
|
||||
sql`UPDATE "bewcloud_news_feed_articles" SET
|
||||
"is_read" = TRUE
|
||||
WHERE "user_id" = $1`,
|
||||
[
|
||||
userId,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchNewsArticles(newsFeed: NewsFeed): Promise<Feed['entries'] | JsonFeed['items']> {
|
||||
try {
|
||||
if (!newsFeed.extra.title || !newsFeed.extra.feed_type || !newsFeed.extra.crawl_type) {
|
||||
throw new Error('Invalid News Feed!');
|
||||
}
|
||||
|
||||
let feed: JsonFeed | Feed | null = null;
|
||||
|
||||
if (newsFeed.extra.crawl_type === 'direct') {
|
||||
feed = await parseUrl(newsFeed.feed_url);
|
||||
} else if (newsFeed.extra.crawl_type === 'googlebot') {
|
||||
feed = await parseUrlAsGooglebot(newsFeed.feed_url);
|
||||
} else if (newsFeed.extra.crawl_type === 'proxy') {
|
||||
feed = await parseUrlWithProxy(newsFeed.feed_url);
|
||||
}
|
||||
|
||||
return (feed as Feed)?.entries || (feed as JsonFeed)?.items || [];
|
||||
} catch (error) {
|
||||
console.log('Failed parsing feed to get articles', newsFeed.feed_url);
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
type FeedArticle = Feed['entries'][number];
|
||||
type JsonFeedArticle = JsonFeed['items'][number];
|
||||
|
||||
const MAX_ARTICLES_CRAWLED_PER_RUN = 10;
|
||||
|
||||
export async function crawlNewsFeed(newsFeed: NewsFeed) {
|
||||
// TODO: Lock this per feedId, so no two processes run this at the same time
|
||||
|
||||
if (!newsFeed.extra.title || !newsFeed.extra.feed_type || !newsFeed.extra.crawl_type) {
|
||||
const feedUrl = await findFeedInUrl(newsFeed.feed_url);
|
||||
|
||||
if (!feedUrl) {
|
||||
throw new Error(
|
||||
`Invalid URL for feed: "${feedUrl}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (feedUrl !== newsFeed.feed_url) {
|
||||
newsFeed.feed_url = feedUrl;
|
||||
}
|
||||
|
||||
const feedInfo = await getFeedInfo(newsFeed.feed_url);
|
||||
|
||||
newsFeed.extra.title = feedInfo.title;
|
||||
newsFeed.extra.feed_type = feedInfo.feed_type;
|
||||
newsFeed.extra.crawl_type = feedInfo.crawl_type;
|
||||
}
|
||||
|
||||
const feedArticles = await fetchNewsArticles(newsFeed);
|
||||
|
||||
const articles: Omit<NewsFeedArticle, 'id' | 'user_id' | 'feed_id' | 'extra' | 'is_read' | 'created_at'>[] = [];
|
||||
|
||||
for (const feedArticle of feedArticles) {
|
||||
// Don't add too many articles per run
|
||||
if (articles.length >= MAX_ARTICLES_CRAWLED_PER_RUN) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const url = (feedArticle as JsonFeedArticle).url || getArticleUrl((feedArticle as FeedArticle).links) ||
|
||||
feedArticle.id;
|
||||
|
||||
const articleIsoDate = (feedArticle as JsonFeedArticle).date_published ||
|
||||
(feedArticle as FeedArticle).published?.toISOString() || (feedArticle as JsonFeedArticle).date_modified ||
|
||||
(feedArticle as FeedArticle).updated?.toISOString();
|
||||
|
||||
const articleDate = articleIsoDate ? new Date(articleIsoDate) : new Date();
|
||||
|
||||
const summary = await parseTextFromHtml(
|
||||
(feedArticle as FeedArticle).description?.value || (feedArticle as FeedArticle).content?.value ||
|
||||
(feedArticle as JsonFeedArticle).content_text || (feedArticle as JsonFeedArticle).content_html ||
|
||||
(feedArticle as JsonFeedArticle).summary || '',
|
||||
);
|
||||
|
||||
if (url) {
|
||||
articles.push({
|
||||
article_title: (feedArticle as FeedArticle).title?.value || (feedArticle as JsonFeedArticle).title ||
|
||||
url.replace('http://', '').replace('https://', ''),
|
||||
article_url: url,
|
||||
article_summary: summary,
|
||||
article_date: articleDate,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const existingArticles = await getNewsArticlesByFeedId(newsFeed.id);
|
||||
const existingArticleUrls = new Set<string>(existingArticles.map((article) => article.article_url));
|
||||
const previousLatestArticleUrl = existingArticles[0]?.article_url;
|
||||
let seenPreviousLatestArticleUrl = false;
|
||||
let addedArticlesCount = 0;
|
||||
|
||||
for (const article of articles) {
|
||||
// Stop looking after seeing the previous latest article
|
||||
if (article.article_url === previousLatestArticleUrl) {
|
||||
seenPreviousLatestArticleUrl = true;
|
||||
}
|
||||
|
||||
if (!seenPreviousLatestArticleUrl && !existingArticleUrls.has(article.article_url)) {
|
||||
try {
|
||||
await createsNewsArticle(newsFeed.user_id, newsFeed.id, article);
|
||||
++addedArticlesCount;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.error(`Failed to add new article: "${article.article_url}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Added', addedArticlesCount, 'new articles');
|
||||
|
||||
newsFeed.last_crawled_at = new Date();
|
||||
|
||||
await updateNewsFeed(newsFeed);
|
||||
}
|
||||
296
lib/data/user.ts
Normal file
296
lib/data/user.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||
import { User, UserSession, VerificationCode } from '/lib/types.ts';
|
||||
import { generateRandomCode } from '/lib/utils.ts';
|
||||
|
||||
const db = new Database();
|
||||
|
||||
export async function isThereAnAdmin() {
|
||||
const user =
|
||||
(await db.query<User>(sql`SELECT * FROM "bewcloud_users" WHERE ("extra" ->> 'is_admin')::boolean IS TRUE LIMIT 1`))[
|
||||
0
|
||||
];
|
||||
|
||||
return Boolean(user);
|
||||
}
|
||||
|
||||
export async function getUserByEmail(email: string) {
|
||||
const lowercaseEmail = email.toLowerCase().trim();
|
||||
|
||||
const user = (await db.query<User>(sql`SELECT * FROM "bewcloud_users" WHERE "email" = $1 LIMIT 1`, [
|
||||
lowercaseEmail,
|
||||
]))[0];
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function getUserById(id: string) {
|
||||
const user = (await db.query<User>(sql`SELECT * FROM "bewcloud_users" WHERE "id" = $1 LIMIT 1`, [
|
||||
id,
|
||||
]))[0];
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function createUser(email: User['email'], hashedPassword: User['hashed_password']) {
|
||||
const trialDays = 30;
|
||||
const now = new Date();
|
||||
const trialEndDate = new Date(new Date().setUTCDate(new Date().getUTCDate() + trialDays));
|
||||
|
||||
const subscription: User['subscription'] = {
|
||||
external: {},
|
||||
expires_at: trialEndDate.toISOString(),
|
||||
updated_at: now.toISOString(),
|
||||
};
|
||||
|
||||
const extra: User['extra'] = { is_email_verified: false };
|
||||
|
||||
// First signup will be an admin "forever"
|
||||
if (!(await isThereAnAdmin())) {
|
||||
extra.is_admin = true;
|
||||
subscription.expires_at = new Date('2100-12-31').toISOString();
|
||||
}
|
||||
|
||||
const newUser = (await db.query<User>(
|
||||
sql`INSERT INTO "bewcloud_users" (
|
||||
"email",
|
||||
"subscription",
|
||||
"status",
|
||||
"hashed_password",
|
||||
"extra"
|
||||
) VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[
|
||||
email,
|
||||
JSON.stringify(subscription),
|
||||
extra.is_admin ? 'active' : 'trial',
|
||||
hashedPassword,
|
||||
JSON.stringify(extra),
|
||||
],
|
||||
))[0];
|
||||
|
||||
return newUser;
|
||||
}
|
||||
|
||||
export async function updateUser(user: User) {
|
||||
await db.query(
|
||||
sql`UPDATE "bewcloud_users" SET
|
||||
"email" = $2,
|
||||
"subscription" = $3,
|
||||
"status" = $4,
|
||||
"hashed_password" = $5,
|
||||
"extra" = $6
|
||||
WHERE "id" = $1`,
|
||||
[
|
||||
user.id,
|
||||
user.email,
|
||||
JSON.stringify(user.subscription),
|
||||
user.status,
|
||||
user.hashed_password,
|
||||
JSON.stringify(user.extra),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteUser(userId: string) {
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_user_sessions" WHERE "user_id" = $1`,
|
||||
[
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_verification_codes" WHERE "user_id" = $1`,
|
||||
[
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_news_feed_articles" WHERE "user_id" = $1`,
|
||||
[
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_news_feeds" WHERE "user_id" = $1`,
|
||||
[
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_users" WHERE "id" = $1`,
|
||||
[
|
||||
userId,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSessionById(id: string) {
|
||||
const session = (await db.query<UserSession>(
|
||||
sql`SELECT * FROM "bewcloud_user_sessions" WHERE "id" = $1 AND "expires_at" > now() LIMIT 1`,
|
||||
[
|
||||
id,
|
||||
],
|
||||
))[0];
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function createUserSession(user: User, isShortLived = false) {
|
||||
const oneMonthFromToday = new Date(new Date().setUTCMonth(new Date().getUTCMonth() + 1));
|
||||
const oneWeekFromToday = new Date(new Date().setUTCDate(new Date().getUTCDate() + 7));
|
||||
|
||||
const newSession: Omit<UserSession, 'id' | 'created_at'> = {
|
||||
user_id: user.id,
|
||||
expires_at: isShortLived ? oneWeekFromToday : oneMonthFromToday,
|
||||
last_seen_at: new Date(),
|
||||
};
|
||||
|
||||
const newUserSessionResult = (await db.query<UserSession>(
|
||||
sql`INSERT INTO "bewcloud_user_sessions" (
|
||||
"user_id",
|
||||
"expires_at",
|
||||
"last_seen_at"
|
||||
) VALUES ($1, $2, $3)
|
||||
RETURNING *`,
|
||||
[
|
||||
newSession.user_id,
|
||||
newSession.expires_at,
|
||||
newSession.last_seen_at,
|
||||
],
|
||||
))[0];
|
||||
|
||||
return newUserSessionResult;
|
||||
}
|
||||
|
||||
export async function updateSession(session: UserSession) {
|
||||
await db.query(
|
||||
sql`UPDATE "bewcloud_user_sessions" SET
|
||||
"expires_at" = $2,
|
||||
"last_seen_at" = $3
|
||||
WHERE "id" = $1`,
|
||||
[
|
||||
session.id,
|
||||
session.expires_at,
|
||||
session.last_seen_at,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteUserSession(sessionId: string) {
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_user_sessions" WHERE "id" = $1`,
|
||||
[
|
||||
sessionId,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function validateUserAndSession(userId: string, sessionId: string) {
|
||||
const user = await getUserById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Not Found');
|
||||
}
|
||||
|
||||
const session = await getSessionById(sessionId);
|
||||
|
||||
if (!session || session.user_id !== user.id) {
|
||||
throw new Error('Not Found');
|
||||
}
|
||||
|
||||
const oneMonthFromToday = new Date(new Date().setUTCMonth(new Date().getUTCMonth() + 1));
|
||||
|
||||
session.last_seen_at = new Date();
|
||||
session.expires_at = oneMonthFromToday;
|
||||
|
||||
await updateSession(session);
|
||||
|
||||
return { user, session };
|
||||
}
|
||||
|
||||
export async function createVerificationCode(
|
||||
user: User,
|
||||
verificationId: string,
|
||||
type: VerificationCode['verification']['type'],
|
||||
) {
|
||||
const inThirtyMinutes = new Date(new Date().setUTCMinutes(new Date().getUTCMinutes() + 30));
|
||||
|
||||
const code = generateRandomCode();
|
||||
|
||||
const newVerificationCode: Omit<VerificationCode, 'id' | 'created_at'> = {
|
||||
user_id: user.id,
|
||||
code,
|
||||
expires_at: inThirtyMinutes,
|
||||
verification: {
|
||||
id: verificationId,
|
||||
type,
|
||||
},
|
||||
};
|
||||
|
||||
await db.query(
|
||||
sql`INSERT INTO "bewcloud_verification_codes" (
|
||||
"user_id",
|
||||
"code",
|
||||
"expires_at",
|
||||
"verification"
|
||||
) VALUES ($1, $2, $3, $4)
|
||||
RETURNING "id"`,
|
||||
[
|
||||
newVerificationCode.user_id,
|
||||
newVerificationCode.code,
|
||||
newVerificationCode.expires_at,
|
||||
JSON.stringify(newVerificationCode.verification),
|
||||
],
|
||||
);
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
export async function validateVerificationCode(
|
||||
user: User,
|
||||
verificationId: string,
|
||||
code: string,
|
||||
type: VerificationCode['verification']['type'],
|
||||
) {
|
||||
const verificationCode = (await db.query<VerificationCode>(
|
||||
sql`SELECT * FROM "bewcloud_verification_codes"
|
||||
WHERE "user_id" = $1 AND
|
||||
"code" = $2 AND
|
||||
"verification" ->> 'type' = $3 AND
|
||||
"verification" ->> 'id' = $4 AND
|
||||
"expires_at" > now()
|
||||
LIMIT 1`,
|
||||
[
|
||||
user.id,
|
||||
code,
|
||||
type,
|
||||
verificationId,
|
||||
],
|
||||
))[0];
|
||||
|
||||
if (verificationCode) {
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_verification_codes" WHERE "id" = $1`,
|
||||
[
|
||||
verificationCode.id,
|
||||
],
|
||||
);
|
||||
} else {
|
||||
throw new Error('Not Found');
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUserContactRevision(id: string) {
|
||||
const user = await getUserById(id);
|
||||
|
||||
const revision = crypto.randomUUID();
|
||||
|
||||
user.extra.contacts_revision = revision;
|
||||
user.extra.contacts_updated_at = new Date().toISOString();
|
||||
|
||||
await updateUser(user);
|
||||
}
|
||||
233
lib/feed.ts
Normal file
233
lib/feed.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { DOMParser, initParser } from 'https://deno.land/x/deno_dom@v0.1.45/deno-dom-wasm-noinit.ts';
|
||||
import { Feed, parseFeed } from 'https://deno.land/x/rss@1.0.0/mod.ts';
|
||||
import { fetchUrl, fetchUrlAsGooglebot, fetchUrlWithProxy, fetchUrlWithRetries } from './utils.ts';
|
||||
import { NewsFeed, NewsFeedCrawlType, NewsFeedType } from './types.ts';
|
||||
|
||||
export interface JsonFeedItem {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
content_text?: string;
|
||||
content_html?: string;
|
||||
summary?: string;
|
||||
date_modified?: string;
|
||||
date_published: string;
|
||||
}
|
||||
|
||||
export interface JsonFeed {
|
||||
version: string;
|
||||
title: string;
|
||||
home_page_url?: string;
|
||||
description?: string;
|
||||
authors?: { name: string; url?: string }[];
|
||||
language?: string;
|
||||
items: JsonFeedItem[];
|
||||
}
|
||||
|
||||
async function getFeedFromUrlContents(urlContents: string) {
|
||||
try {
|
||||
const jsonFeed = JSON.parse(urlContents) as JsonFeed;
|
||||
return jsonFeed;
|
||||
} catch (_error) {
|
||||
const feed = await parseFeed(urlContents);
|
||||
return feed;
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseUrl(feedUrl: string) {
|
||||
const urlContents = await fetchUrl(feedUrl);
|
||||
const feed = await getFeedFromUrlContents(urlContents);
|
||||
return feed;
|
||||
}
|
||||
|
||||
export async function parseUrlAsGooglebot(feedUrl: string) {
|
||||
const urlContents = await fetchUrlAsGooglebot(feedUrl);
|
||||
const feed = await getFeedFromUrlContents(urlContents);
|
||||
return feed;
|
||||
}
|
||||
|
||||
export async function parseUrlWithProxy(feedUrl: string) {
|
||||
const urlContents = await fetchUrlWithProxy(feedUrl);
|
||||
const feed = await getFeedFromUrlContents(urlContents);
|
||||
return feed;
|
||||
}
|
||||
|
||||
async function parseUrlWithRetries(feedUrl: string): Promise<{ feed: JsonFeed | Feed; crawlType: NewsFeedCrawlType }> {
|
||||
try {
|
||||
const feed = await parseUrl(feedUrl);
|
||||
return { feed, crawlType: 'direct' };
|
||||
} catch (_error) {
|
||||
try {
|
||||
const feed = await parseUrlAsGooglebot(feedUrl);
|
||||
return { feed, crawlType: 'googlebot' };
|
||||
} catch (_error) {
|
||||
const feed = await parseUrlWithProxy(feedUrl);
|
||||
return { feed, crawlType: 'proxy' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function isValid(feedUrl: string, fastFail = false) {
|
||||
try {
|
||||
console.log('Checking if URL is a valid feed URL', feedUrl);
|
||||
const { feed } = fastFail ? { feed: await parseUrl(feedUrl) } : await parseUrlWithRetries(feedUrl);
|
||||
return Boolean(
|
||||
(feed as Feed).title?.value || (feed as JsonFeed).title || (feed as JsonFeed).items?.length ||
|
||||
(feed as Feed).links?.length > 0 || feed.description,
|
||||
);
|
||||
} catch (error) {
|
||||
console.log('Failed parsing feed to check validity', feedUrl);
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function getFeedInfo(feedUrl: string, fastFail = false): Promise<NewsFeed['extra']> {
|
||||
try {
|
||||
console.log('Getting Feed URL info', feedUrl);
|
||||
|
||||
const { feed, crawlType } = fastFail
|
||||
? { feed: await parseUrl(feedUrl), crawlType: 'direct' as const }
|
||||
: await parseUrlWithRetries(feedUrl);
|
||||
let feedType: NewsFeedType = 'rss';
|
||||
|
||||
if ((feed as JsonFeed).version) {
|
||||
feedType = 'json';
|
||||
} else if ((feed as Feed).type === 'ATOM') {
|
||||
feedType = 'atom';
|
||||
}
|
||||
|
||||
return {
|
||||
title: (feed as Feed).title?.value || (feed as JsonFeed).title || '',
|
||||
feed_type: feedType,
|
||||
crawl_type: crawlType,
|
||||
};
|
||||
} catch (error) {
|
||||
console.log('Failed parsing feed to check validity', feedUrl);
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export async function findFeedInUrl(url: string) {
|
||||
let urlContents = '';
|
||||
try {
|
||||
urlContents = await fetchUrl(url);
|
||||
} catch (error) {
|
||||
console.log('Failed to fetch URL to find feed', url);
|
||||
console.log(error);
|
||||
return null;
|
||||
}
|
||||
|
||||
await initParser();
|
||||
|
||||
try {
|
||||
const document = new DOMParser().parseFromString(urlContents, 'text/html');
|
||||
|
||||
const urlOptions = [
|
||||
url,
|
||||
document!.querySelector('link[type="application/rss+xml"]')?.getAttribute('href'),
|
||||
document!.querySelector('link[type="application/atom+xml"]')?.getAttribute('href'),
|
||||
document!.querySelector('link[rel="alternate"]')?.getAttribute('href'),
|
||||
// Try some common URL paths
|
||||
'feed',
|
||||
'rss',
|
||||
'rss.xml',
|
||||
'feed.xml',
|
||||
'atom.xml',
|
||||
'atom',
|
||||
'feeds/posts/default',
|
||||
].filter(Boolean);
|
||||
|
||||
for (const urlOption of urlOptions) {
|
||||
const optionalSlash = urlOption!.startsWith('/') || url.endsWith('/') ? '' : '/';
|
||||
const potentialFeedUrl = urlOption!.startsWith('http') ? urlOption : `${url}${optionalSlash}${urlOption}`;
|
||||
|
||||
try {
|
||||
const isValidFeed = await isValid(potentialFeedUrl!, true);
|
||||
|
||||
if (isValidFeed) {
|
||||
return potentialFeedUrl;
|
||||
}
|
||||
} catch (_error) {
|
||||
// Do nothing.
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// This error can happen for huge responses, but that usually means the URL works
|
||||
if (error.toString().includes('RangeError: Maximum call stack size exceeded')) {
|
||||
return url;
|
||||
} else {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getArticleUrl(links: Feed['entries'][0]['links']) {
|
||||
try {
|
||||
for (const link of links) {
|
||||
if (link.rel === 'alternate' && link.type?.startsWith('text/html')) {
|
||||
return link.href || '';
|
||||
}
|
||||
}
|
||||
|
||||
return links[0]?.href || '';
|
||||
} catch (_error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUrlInfo(url: string): Promise<{ title: string; htmlBody: string; textBody: string } | null> {
|
||||
let urlContents = '';
|
||||
try {
|
||||
urlContents = await fetchUrlWithRetries(url);
|
||||
} catch (error) {
|
||||
console.log('Failed to fetch URL to get info', url);
|
||||
console.log(error);
|
||||
return null;
|
||||
}
|
||||
|
||||
await initParser();
|
||||
|
||||
const document = new DOMParser().parseFromString(urlContents, 'text/html');
|
||||
|
||||
const title = document!.querySelector('title')?.textContent;
|
||||
let htmlBody = document!.querySelector('body')?.innerHTML;
|
||||
let textBody = document!.querySelector('body')?.textContent;
|
||||
|
||||
const mainHtml = document!.querySelector('main')?.innerHTML;
|
||||
const mainText = document!.querySelector('main')?.textContent;
|
||||
|
||||
const articleHtml = document!.querySelector('article')?.innerHTML;
|
||||
const articleText = document!.querySelector('article')?.textContent;
|
||||
|
||||
if (mainHtml && mainText) {
|
||||
htmlBody = mainHtml;
|
||||
textBody = mainText;
|
||||
} else if (articleHtml && articleText) {
|
||||
htmlBody = articleHtml;
|
||||
textBody = articleText;
|
||||
}
|
||||
|
||||
if (!title || !htmlBody || !textBody) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { title, htmlBody, textBody };
|
||||
}
|
||||
|
||||
export async function parseTextFromHtml(html: string): Promise<string> {
|
||||
let text = '';
|
||||
|
||||
await initParser();
|
||||
|
||||
const document = new DOMParser().parseFromString(html, 'text/html');
|
||||
|
||||
text = document!.textContent;
|
||||
|
||||
return text;
|
||||
}
|
||||
185
lib/form-utils.tsx
Normal file
185
lib/form-utils.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
export interface FormField {
|
||||
name: string;
|
||||
label: string;
|
||||
value?: string | null;
|
||||
overrideValue?: string;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
type:
|
||||
| 'text'
|
||||
| 'email'
|
||||
| 'tel'
|
||||
| 'url'
|
||||
| 'date'
|
||||
| 'number'
|
||||
| 'range'
|
||||
| 'select'
|
||||
| 'textarea'
|
||||
| 'checkbox'
|
||||
| 'hidden'
|
||||
| 'password';
|
||||
step?: string;
|
||||
max?: string;
|
||||
min?: string;
|
||||
rows?: string;
|
||||
options?: {
|
||||
label: string;
|
||||
value: string;
|
||||
}[];
|
||||
checked?: boolean;
|
||||
multiple?: boolean;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
extraClasses?: string;
|
||||
}
|
||||
|
||||
export function getFormDataField(formData: FormData, field: string) {
|
||||
return ((formData.get(field) || '') as string).trim();
|
||||
}
|
||||
|
||||
export function getFormDataFieldArray(formData: FormData, field: string) {
|
||||
return ((formData.getAll(field) || []) as string[]).map((value) => value.trim());
|
||||
}
|
||||
|
||||
export function generateFieldHtml(
|
||||
field: FormField,
|
||||
formData: FormData,
|
||||
) {
|
||||
let value = field.overrideValue ||
|
||||
(field.multiple ? getFormDataFieldArray(formData, field.name) : getFormDataField(formData, field.name)) ||
|
||||
field.value;
|
||||
|
||||
if (typeof field.overrideValue !== 'undefined') {
|
||||
value = field.overrideValue;
|
||||
}
|
||||
|
||||
if (field.type === 'hidden') {
|
||||
return generateInputHtml(field, value);
|
||||
}
|
||||
|
||||
return (
|
||||
<fieldset class={`block mb-4 ${field.extraClasses || ''}`}>
|
||||
<label class='text-slate-300 block pb-1' for={`field_${field.name}`}>{field.label}</label>
|
||||
{generateInputHtml(field, value)}
|
||||
{field.description
|
||||
? (
|
||||
<aside class={`text-sm text-slate-400 p-2 ${field.type === 'checkbox' ? 'inline' : ''}`}>
|
||||
{field.description}
|
||||
</aside>
|
||||
)
|
||||
: null}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function generateInputHtml(
|
||||
{
|
||||
name,
|
||||
placeholder,
|
||||
type,
|
||||
options,
|
||||
step,
|
||||
max,
|
||||
min,
|
||||
rows,
|
||||
checked,
|
||||
multiple,
|
||||
disabled,
|
||||
required,
|
||||
readOnly,
|
||||
}: FormField,
|
||||
value?: string | string[] | null,
|
||||
) {
|
||||
const additionalAttributes: Record<string, string | number | boolean> = {};
|
||||
|
||||
if (typeof step !== 'undefined') {
|
||||
additionalAttributes.step = parseInt(step, 10);
|
||||
}
|
||||
if (typeof max !== 'undefined') {
|
||||
additionalAttributes.max = parseInt(max, 10);
|
||||
}
|
||||
if (typeof min !== 'undefined') {
|
||||
additionalAttributes.min = parseInt(min, 10);
|
||||
}
|
||||
if (typeof rows !== 'undefined') {
|
||||
additionalAttributes.rows = parseInt(rows, 10);
|
||||
}
|
||||
if (checked === true && type === 'checkbox' && value) {
|
||||
additionalAttributes.checked = true;
|
||||
}
|
||||
if (multiple === true) {
|
||||
additionalAttributes.multiple = true;
|
||||
}
|
||||
if (required === true) {
|
||||
additionalAttributes.required = true;
|
||||
}
|
||||
if (disabled === true) {
|
||||
additionalAttributes.disabled = true;
|
||||
}
|
||||
if (readOnly === true) {
|
||||
additionalAttributes.readonly = true;
|
||||
}
|
||||
|
||||
if (type === 'select') {
|
||||
return (
|
||||
<select class='mt-1 input-field' id={`field_${name}`} name={name} type={type} {...additionalAttributes}>
|
||||
{options?.map((option) => (
|
||||
<option
|
||||
value={option.value}
|
||||
selected={option.value === value || (multiple && (value || [])?.includes(option.value))}
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'textarea') {
|
||||
return (
|
||||
<textarea
|
||||
class='mt-1 input-field'
|
||||
id={`field_${name}`}
|
||||
name={name}
|
||||
rows={6}
|
||||
placeholder={placeholder}
|
||||
{...additionalAttributes}
|
||||
>
|
||||
{(value as string) || ''}
|
||||
</textarea>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'checkbox') {
|
||||
return (
|
||||
<input id={`field_${name}`} name={name} type={type} value={value as string || ''} {...additionalAttributes} />
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'password') {
|
||||
return (
|
||||
<input
|
||||
class='mt-1 input-field'
|
||||
id={`field_${name}`}
|
||||
name={name}
|
||||
type={type}
|
||||
placeholder={placeholder || ''}
|
||||
value=''
|
||||
{...additionalAttributes}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
class='mt-1 input-field'
|
||||
id={`field_${name}`}
|
||||
name={name}
|
||||
type={type}
|
||||
placeholder={placeholder || ''}
|
||||
value={value as string || ''}
|
||||
{...additionalAttributes}
|
||||
/>
|
||||
);
|
||||
}
|
||||
76
lib/interfaces/database.ts
Normal file
76
lib/interfaces/database.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Client } from 'https://deno.land/x/postgres@v0.19.2/mod.ts';
|
||||
import 'std/dotenv/load.ts';
|
||||
|
||||
const POSTGRESQL_HOST = Deno.env.get('POSTGRESQL_HOST') || '';
|
||||
const POSTGRESQL_USER = Deno.env.get('POSTGRESQL_USER') || '';
|
||||
const POSTGRESQL_PASSWORD = Deno.env.get('POSTGRESQL_PASSWORD') || '';
|
||||
const POSTGRESQL_DBNAME = Deno.env.get('POSTGRESQL_DBNAME') || '';
|
||||
const POSTGRESQL_PORT = Deno.env.get('POSTGRESQL_PORT') || '';
|
||||
const POSTGRESQL_CAFILE = Deno.env.get('POSTGRESQL_CAFILE') || '';
|
||||
|
||||
const tls = POSTGRESQL_CAFILE
|
||||
? {
|
||||
enabled: true,
|
||||
enforce: false,
|
||||
caCertificates: [await Deno.readTextFile(POSTGRESQL_CAFILE)],
|
||||
}
|
||||
: {
|
||||
enabled: true,
|
||||
enforce: false,
|
||||
};
|
||||
|
||||
export default class Database {
|
||||
protected db?: Client;
|
||||
|
||||
constructor(connectNow = false) {
|
||||
if (connectNow) {
|
||||
this.connectToPostgres();
|
||||
}
|
||||
}
|
||||
|
||||
protected async connectToPostgres() {
|
||||
if (this.db) {
|
||||
return this.db;
|
||||
}
|
||||
|
||||
const postgresClient = new Client({
|
||||
user: POSTGRESQL_USER,
|
||||
password: POSTGRESQL_PASSWORD,
|
||||
database: POSTGRESQL_DBNAME,
|
||||
hostname: POSTGRESQL_HOST,
|
||||
port: POSTGRESQL_PORT,
|
||||
tls,
|
||||
});
|
||||
|
||||
await postgresClient.connect();
|
||||
|
||||
this.db = postgresClient;
|
||||
}
|
||||
|
||||
protected async disconnectFromPostgres() {
|
||||
if (!this.db) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.db.end();
|
||||
|
||||
this.db = undefined;
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.disconnectFromPostgres();
|
||||
}
|
||||
|
||||
public async query<T>(sql: string, args?: any[]) {
|
||||
if (!this.db) {
|
||||
await this.connectToPostgres();
|
||||
}
|
||||
|
||||
const result = await this.db!.queryObject<T>(sql, args);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
}
|
||||
|
||||
// This allows us to have nice SQL syntax highlighting in template literals
|
||||
export const sql = String.raw;
|
||||
83
lib/providers/brevo.ts
Normal file
83
lib/providers/brevo.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import 'std/dotenv/load.ts';
|
||||
|
||||
import { helpEmail } from '/lib/utils.ts';
|
||||
|
||||
const BREVO_API_KEY = Deno.env.get('BREVO_API_KEY') || '';
|
||||
|
||||
enum BrevoTemplateId {
|
||||
BEWCLOUD_VERIFY_EMAIL = 20,
|
||||
}
|
||||
|
||||
interface BrevoResponse {
|
||||
messageId?: string;
|
||||
code?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
function getApiRequestHeaders() {
|
||||
return {
|
||||
'Api-Key': BREVO_API_KEY,
|
||||
'Accept': 'application/json; charset=utf-8',
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
};
|
||||
}
|
||||
|
||||
interface BrevoRequestBody {
|
||||
templateId?: number;
|
||||
params: Record<string, any> | null;
|
||||
to: { email: string; name?: string }[];
|
||||
cc?: { email: string; name?: string }[];
|
||||
bcc?: { email: string; name?: string }[];
|
||||
htmlContent?: string;
|
||||
textContent?: string;
|
||||
subject?: string;
|
||||
replyTo: { email: string; name?: string };
|
||||
tags?: string[];
|
||||
attachment?: { name: string; content: string; url: string }[];
|
||||
}
|
||||
|
||||
async function sendEmailWithTemplate(
|
||||
to: string,
|
||||
templateId: BrevoTemplateId,
|
||||
data: BrevoRequestBody['params'],
|
||||
attachments: BrevoRequestBody['attachment'] = [],
|
||||
cc?: string,
|
||||
) {
|
||||
const email: BrevoRequestBody = {
|
||||
templateId,
|
||||
params: data,
|
||||
to: [{ email: to }],
|
||||
replyTo: { email: helpEmail },
|
||||
};
|
||||
|
||||
if (attachments?.length) {
|
||||
email.attachment = attachments;
|
||||
}
|
||||
|
||||
if (cc) {
|
||||
email.cc = [{ email: cc }];
|
||||
}
|
||||
|
||||
const brevoResponse = await fetch('https://api.brevo.com/v3/smtp/email', {
|
||||
method: 'POST',
|
||||
headers: getApiRequestHeaders(),
|
||||
body: JSON.stringify(email),
|
||||
});
|
||||
const brevoResult = (await brevoResponse.json()) as BrevoResponse;
|
||||
|
||||
if (brevoResult.code || brevoResult.message) {
|
||||
console.log(JSON.stringify({ brevoResult }, null, 2));
|
||||
throw new Error(`Failed to send email "${templateId}"`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendVerifyEmailEmail(
|
||||
email: string,
|
||||
verificationCode: string,
|
||||
) {
|
||||
const data = {
|
||||
verificationCode,
|
||||
};
|
||||
|
||||
await sendEmailWithTemplate(email, BrevoTemplateId.BEWCLOUD_VERIFY_EMAIL, data);
|
||||
}
|
||||
131
lib/types.ts
Normal file
131
lib/types.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
hashed_password: string;
|
||||
subscription: {
|
||||
external: Record<never, never>;
|
||||
expires_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
status: 'trial' | 'active' | 'inactive';
|
||||
extra: {
|
||||
is_email_verified: boolean;
|
||||
is_admin?: boolean;
|
||||
dav_hashed_password?: string;
|
||||
contacts_revision?: string;
|
||||
contacts_updated_at?: string;
|
||||
};
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface UserSession {
|
||||
id: string;
|
||||
user_id: string;
|
||||
expires_at: Date;
|
||||
last_seen_at: Date;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface FreshContextState {
|
||||
user?: User;
|
||||
session?: UserSession;
|
||||
}
|
||||
|
||||
export interface VerificationCode {
|
||||
id: string;
|
||||
user_id: string;
|
||||
code: string;
|
||||
verification: {
|
||||
type: 'email';
|
||||
id: string;
|
||||
};
|
||||
expires_at: Date;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface DashboardLink {
|
||||
url: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Dashboard {
|
||||
id: string;
|
||||
user_id: string;
|
||||
data: {
|
||||
links: DashboardLink[];
|
||||
notes: string;
|
||||
};
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export type NewsFeedType = 'rss' | 'atom' | 'json';
|
||||
export type NewsFeedCrawlType = 'direct' | 'googlebot' | 'proxy';
|
||||
|
||||
export interface NewsFeed {
|
||||
id: string;
|
||||
user_id: string;
|
||||
feed_url: string;
|
||||
last_crawled_at: Date | null;
|
||||
extra: {
|
||||
title?: string;
|
||||
feed_type?: NewsFeedType;
|
||||
crawl_type?: NewsFeedCrawlType;
|
||||
};
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface NewsFeedArticle {
|
||||
id: string;
|
||||
user_id: string;
|
||||
feed_id: string;
|
||||
article_url: string;
|
||||
article_title: string;
|
||||
article_summary: string;
|
||||
article_date: Date;
|
||||
is_read: boolean;
|
||||
extra: Record<never, never>;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
// NOTE: I don't really organize contacts by groups or address books, so I don't think I'll need that complexity
|
||||
export interface Contact {
|
||||
id: string;
|
||||
user_id: string;
|
||||
revision: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
extra: {
|
||||
name_title?: string;
|
||||
middle_names?: string[];
|
||||
organization?: string;
|
||||
role?: string;
|
||||
photo_url?: string;
|
||||
photo_mediatype?: string;
|
||||
addresses?: ContactAddress[];
|
||||
fields?: ContactField[];
|
||||
notes?: string;
|
||||
uid?: string;
|
||||
nickname?: string;
|
||||
birthday?: string;
|
||||
};
|
||||
updated_at: Date;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface ContactAddress {
|
||||
label?: string;
|
||||
line_1?: string;
|
||||
line_2?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
postal_code?: string;
|
||||
country?: string;
|
||||
}
|
||||
|
||||
export type ContactFieldType = 'email' | 'phone' | 'url' | 'other';
|
||||
|
||||
export interface ContactField {
|
||||
name: string;
|
||||
value: string;
|
||||
type: ContactFieldType;
|
||||
}
|
||||
616
lib/utils.ts
Normal file
616
lib/utils.ts
Normal file
@@ -0,0 +1,616 @@
|
||||
import { Contact, ContactAddress, ContactField } from './types.ts';
|
||||
|
||||
export const baseUrl = 'https://app.bewcloud.com';
|
||||
export const defaultTitle = 'bewCloud is a modern and simpler alternative to Nextcloud and ownCloud';
|
||||
export const defaultDescription = `Have your calendar, contacts, tasks, and files under your own control.`;
|
||||
export const helpEmail = 'help@bewcloud.com';
|
||||
|
||||
export const CONTACTS_PER_PAGE_COUNT = 20;
|
||||
|
||||
export const DAV_RESPONSE_HEADER = '1, 3, 4, addressbook';
|
||||
// '1, 3, extended-mkcol, access-control, calendarserver-principal-property-search, oc-resource-sharing, addressbook, nextcloud-checksum-update, nc-calendar-search, nc-enable-birthday-calendar'
|
||||
// '1, 3, extended-mkcol, access-control, calendarserver-principal-property-search, calendar-access, calendar-proxy, calendar-auto-schedule, calendar-availability, nc-calendar-trashbin, nc-calendar-webcal-cache, calendarserver-subscribed, oc-resource-sharing, oc-calendar-publishing, calendarserver-sharing, addressbook, nextcloud-checksum-update, nc-calendar-search, nc-enable-birthday-calendar
|
||||
|
||||
export function isRunningLocally(request: Request) {
|
||||
return request.url.includes('localhost');
|
||||
}
|
||||
|
||||
export function escapeHtml(unsafe: string) {
|
||||
return unsafe.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
export function escapeXml(unsafe: string) {
|
||||
return escapeHtml(unsafe).replaceAll('\r', ' ');
|
||||
}
|
||||
|
||||
export function generateRandomCode(length = 6) {
|
||||
const getRandomDigit = () => Math.floor(Math.random() * (10)); // 0-9
|
||||
|
||||
const codeDigits = Array.from({ length }).map(getRandomDigit);
|
||||
|
||||
return codeDigits.join('');
|
||||
}
|
||||
|
||||
export async function generateHash(value: string, algorithm: AlgorithmIdentifier) {
|
||||
const hashedValueData = await crypto.subtle.digest(
|
||||
algorithm,
|
||||
new TextEncoder().encode(value),
|
||||
);
|
||||
|
||||
const hashedValue = Array.from(new Uint8Array(hashedValueData)).map(
|
||||
(byte) => byte.toString(16).padStart(2, '0'),
|
||||
).join('');
|
||||
|
||||
return hashedValue;
|
||||
}
|
||||
|
||||
export function splitArrayInChunks<T = any>(array: T[], chunkLength: number) {
|
||||
const chunks = [];
|
||||
let chunkIndex = 0;
|
||||
const arrayLength = array.length;
|
||||
|
||||
while (chunkIndex < arrayLength) {
|
||||
chunks.push(array.slice(chunkIndex, chunkIndex += chunkLength));
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
export function validateEmail(email: string) {
|
||||
const trimmedEmail = (email || '').trim().toLocaleLowerCase();
|
||||
if (!trimmedEmail) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const requiredCharsNotInEdges = ['@', '.'];
|
||||
return requiredCharsNotInEdges.every((char) =>
|
||||
trimmedEmail.includes(char) && !trimmedEmail.startsWith(char) && !trimmedEmail.endsWith(char)
|
||||
);
|
||||
}
|
||||
|
||||
export function validateUrl(url: string) {
|
||||
const trimmedUrl = (url || '').trim().toLocaleLowerCase();
|
||||
if (!trimmedUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!trimmedUrl.includes('://')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const protocolIndex = trimmedUrl.indexOf('://');
|
||||
const urlAfterProtocol = trimmedUrl.substring(protocolIndex + 3);
|
||||
|
||||
if (!urlAfterProtocol) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Adapted from https://gist.github.com/fasiha/7f20043a12ce93401d8473aee037d90a
|
||||
export async function concurrentPromises<T>(
|
||||
generators: (() => Promise<T>)[],
|
||||
maxConcurrency: number,
|
||||
): Promise<T[]> {
|
||||
const iterator = generators.entries();
|
||||
|
||||
const results: T[] = [];
|
||||
|
||||
let hasFailed = false;
|
||||
|
||||
await Promise.all(
|
||||
Array.from(Array(maxConcurrency), async () => {
|
||||
for (const [index, promiseToExecute] of iterator) {
|
||||
if (hasFailed) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
results[index] = await promiseToExecute();
|
||||
} catch (error) {
|
||||
hasFailed = true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
const MAX_RESPONSE_TIME_IN_MS = 10 * 1000;
|
||||
|
||||
export async function fetchUrl(url: string) {
|
||||
const abortController = new AbortController();
|
||||
const requestCancelTimeout = setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, MAX_RESPONSE_TIME_IN_MS);
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
if (requestCancelTimeout) {
|
||||
clearTimeout(requestCancelTimeout);
|
||||
}
|
||||
|
||||
const urlContents = await response.text();
|
||||
return urlContents;
|
||||
}
|
||||
|
||||
export async function fetchUrlAsGooglebot(url: string) {
|
||||
const abortController = new AbortController();
|
||||
const requestCancelTimeout = setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, MAX_RESPONSE_TIME_IN_MS);
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
|
||||
},
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
if (requestCancelTimeout) {
|
||||
clearTimeout(requestCancelTimeout);
|
||||
}
|
||||
|
||||
const urlContents = await response.text();
|
||||
return urlContents;
|
||||
}
|
||||
|
||||
export async function fetchUrlWithProxy(url: string) {
|
||||
const abortController = new AbortController();
|
||||
const requestCancelTimeout = setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, MAX_RESPONSE_TIME_IN_MS);
|
||||
|
||||
const response = await fetch(`https://api.allorigins.win/raw?url=${url}`, {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
if (requestCancelTimeout) {
|
||||
clearTimeout(requestCancelTimeout);
|
||||
}
|
||||
|
||||
const urlContents = await response.text();
|
||||
return urlContents;
|
||||
}
|
||||
|
||||
export async function fetchUrlWithRetries(url: string) {
|
||||
try {
|
||||
const text = await fetchUrl(url);
|
||||
return text;
|
||||
} catch (_error) {
|
||||
try {
|
||||
const text = await fetchUrlAsGooglebot(url);
|
||||
return text;
|
||||
} catch (_error) {
|
||||
const text = await fetchUrlWithProxy(url);
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function convertFormDataToObject(formData: FormData): Record<string, any> {
|
||||
return JSON.parse(JSON.stringify(Object.fromEntries(formData)));
|
||||
}
|
||||
|
||||
export function convertObjectToFormData(formDataObject: Record<string, any>): FormData {
|
||||
const formData = new FormData();
|
||||
|
||||
for (const key of Object.keys(formDataObject || {})) {
|
||||
if (Array.isArray(formDataObject[key])) {
|
||||
formData.append(key, formDataObject[key].join(','));
|
||||
} else {
|
||||
formData.append(key, formDataObject[key]);
|
||||
}
|
||||
}
|
||||
|
||||
return formData;
|
||||
}
|
||||
|
||||
function writeXmlTag(tagName: string, value: any, attributes?: Record<string, any>) {
|
||||
const attributesXml = attributes
|
||||
? Object.keys(attributes || {}).map((attributeKey) => `${attributeKey}="${escapeHtml(attributes[attributeKey])}"`)
|
||||
.join(' ')
|
||||
: '';
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return `<${tagName}${attributesXml ? ` ${attributesXml}` : ''} />`;
|
||||
}
|
||||
|
||||
const xmlLines: string[] = [];
|
||||
|
||||
for (const valueItem of value) {
|
||||
xmlLines.push(writeXmlTag(tagName, valueItem));
|
||||
}
|
||||
|
||||
return xmlLines.join('\n');
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
if (Object.keys(value).length === 0) {
|
||||
return `<${tagName}${attributesXml ? ` ${attributesXml}` : ''} />`;
|
||||
}
|
||||
|
||||
return `<${tagName}${attributesXml ? ` ${attributesXml}` : ''}>${convertObjectToDavXml(value)}</${tagName}>`;
|
||||
}
|
||||
|
||||
return `<${tagName}${attributesXml ? ` ${attributesXml}` : ''}>${value}</${tagName}>`;
|
||||
}
|
||||
|
||||
export function convertObjectToDavXml(davObject: Record<string, any>, isInitial = false): string {
|
||||
const xmlLines: string[] = [];
|
||||
|
||||
if (isInitial) {
|
||||
xmlLines.push(`<?xml version="1.0" encoding="UTF-8"?>`);
|
||||
}
|
||||
|
||||
for (const key of Object.keys(davObject)) {
|
||||
if (key.endsWith('_attributes')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
xmlLines.push(writeXmlTag(key, davObject[key], davObject[`${key}_attributes`]));
|
||||
}
|
||||
|
||||
return xmlLines.join('\n');
|
||||
}
|
||||
|
||||
function addLeadingZero(number: number) {
|
||||
if (number < 10) {
|
||||
return `0${number}`;
|
||||
}
|
||||
|
||||
return number.toString();
|
||||
}
|
||||
|
||||
export function buildRFC822Date(dateString: string) {
|
||||
const dayStrings = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const monthStrings = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
const timeStamp = Date.parse(dateString);
|
||||
const date = new Date(timeStamp);
|
||||
|
||||
const day = dayStrings[date.getDay()];
|
||||
const dayNumber = addLeadingZero(date.getUTCDate());
|
||||
const month = monthStrings[date.getUTCMonth()];
|
||||
const year = date.getUTCFullYear();
|
||||
const time = `${addLeadingZero(date.getUTCHours())}:${addLeadingZero(date.getUTCMinutes())}:00`;
|
||||
|
||||
return `${day}, ${dayNumber} ${month} ${year} ${time} +0000`;
|
||||
}
|
||||
|
||||
export function formatContactToVCard(contacts: Contact[]): string {
|
||||
const vCardText = contacts.map((contact) =>
|
||||
`BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
N:${contact.last_name};${contact.first_name};${
|
||||
contact.extra.middle_names ? contact.extra.middle_names?.map((name) => name.trim()).filter(Boolean).join(' ') : ''
|
||||
};${contact.extra.name_title || ''};
|
||||
FN:${contact.extra.name_title ? `${contact.extra.name_title || ''} ` : ''}${contact.first_name} ${contact.last_name}
|
||||
${contact.extra.organization ? `ORG:${contact.extra.organization.replaceAll(',', '\\,')}` : ''}
|
||||
${contact.extra.role ? `TITLE:${contact.extra.role}` : ''}
|
||||
${contact.extra.birthday ? `BDAY:${contact.extra.birthday}` : ''}
|
||||
${contact.extra.nickname ? `NICKNAME:${contact.extra.nickname}` : ''}
|
||||
${contact.extra.photo_url ? `PHOTO;MEDIATYPE=${contact.extra.photo_mediatype}:${contact.extra.photo_url}` : ''}
|
||||
${
|
||||
contact.extra.fields?.filter((field) => field.type === 'phone').map((phone) =>
|
||||
`TEL;TYPE=${phone.name}:${phone.value}`
|
||||
).join('\n') || ''
|
||||
}
|
||||
${
|
||||
contact.extra.addresses?.map((address) =>
|
||||
`ADR;TYPE=${address.label}:${(address.line_2 || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')};${
|
||||
(address.line_1 || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')
|
||||
};${(address.city || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')};${
|
||||
(address.state || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')
|
||||
};${(address.postal_code || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')};${
|
||||
(address.country || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')
|
||||
}`
|
||||
).join('\n') || ''
|
||||
}
|
||||
${
|
||||
contact.extra.fields?.filter((field) => field.type === 'email').map((email) =>
|
||||
`EMAIL;TYPE=${email.name}:${email.value}`
|
||||
).join('\n') || ''
|
||||
}
|
||||
REV:${new Date(contact.updated_at).toISOString()}
|
||||
${
|
||||
contact.extra.fields?.filter((field) => field.type === 'other').map((other) => `x-${other.name}:${other.value}`)
|
||||
.join('\n') || ''
|
||||
}
|
||||
${
|
||||
contact.extra.notes
|
||||
? `NOTE:${contact.extra.notes.replaceAll('\r', '').replaceAll('\n', '\\n').replaceAll(',', '\\,')}`
|
||||
: ''
|
||||
}
|
||||
${contact.extra.uid ? `UID:${contact.extra.uid}` : ''}
|
||||
END:VCARD`
|
||||
).join('\n');
|
||||
|
||||
return vCardText.split('\n').map((line) => line.trim()).filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
type VCardVersion = '2.1' | '3.0' | '4.0';
|
||||
|
||||
export function parseVCardFromTextContents(text: string): Partial<Contact>[] {
|
||||
const lines = text.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||
|
||||
const partialContacts: Partial<Contact>[] = [];
|
||||
|
||||
let partialContact: Partial<Contact> = {};
|
||||
let vCardVersion: VCardVersion = '2.1';
|
||||
|
||||
// Loop through every line
|
||||
for (const line of lines) {
|
||||
// Start new contact and vCard version
|
||||
if (line.startsWith('BEGIN:VCARD')) {
|
||||
partialContact = {};
|
||||
vCardVersion = '2.1';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Finish contact
|
||||
if (line.startsWith('END:VCARD')) {
|
||||
partialContacts.push(partialContact);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Select proper vCard version
|
||||
if (line.startsWith('VERSION:')) {
|
||||
if (line.startsWith('VERSION:2.1')) {
|
||||
vCardVersion = '2.1';
|
||||
} else if (line.startsWith('VERSION:3.0')) {
|
||||
vCardVersion = '3.0';
|
||||
} else if (line.startsWith('VERSION:4.0')) {
|
||||
vCardVersion = '4.0';
|
||||
} else {
|
||||
// Default to 2.1, log warning
|
||||
vCardVersion = '2.1';
|
||||
console.warn(`Invalid vCard version found: "${line}". Defaulting to 2.1 parser.`);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (vCardVersion !== '2.1' && vCardVersion !== '3.0' && vCardVersion !== '4.0') {
|
||||
vCardVersion = '2.1';
|
||||
console.warn(`Invalid vCard version found: "${vCardVersion}". Defaulting to 2.1 parser.`);
|
||||
}
|
||||
|
||||
if (line.startsWith('UID:')) {
|
||||
const uid = line.replace('UID:', '');
|
||||
|
||||
if (!uid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
uid,
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('N:')) {
|
||||
const names = line.split('N:')[1].split(';');
|
||||
|
||||
const lastName = names[0] || '';
|
||||
const firstName = names[1] || '';
|
||||
const middleNames = names.slice(2, -1).filter(Boolean);
|
||||
const title = names.slice(-1).join(' ') || '';
|
||||
|
||||
if (!firstName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
partialContact.first_name = firstName;
|
||||
partialContact.last_name = lastName;
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
middle_names: middleNames,
|
||||
name_title: title,
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('ORG:')) {
|
||||
const organization = ((line.split('ORG:')[1] || '').split(';').join(' ') || '').replaceAll('\\,', ',');
|
||||
|
||||
if (!organization) {
|
||||
continue;
|
||||
}
|
||||
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
organization,
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('BDAY:')) {
|
||||
const birthday = line.split('BDAY:')[1] || '';
|
||||
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
birthday,
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('NICKNAME:')) {
|
||||
const nickname = (line.split('NICKNAME:')[1] || '').split(';').join(' ') || '';
|
||||
|
||||
if (!nickname) {
|
||||
continue;
|
||||
}
|
||||
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
nickname,
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('TITLE:')) {
|
||||
const role = line.split('TITLE:')[1] || '';
|
||||
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
role,
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('NOTE:')) {
|
||||
const notes = (line.split('NOTE:')[1] || '').replaceAll('\\n', '\n').replaceAll('\\,', ',');
|
||||
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
notes,
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.includes('ADR;')) {
|
||||
const addressInfo = line.split('ADR;')[1] || '';
|
||||
const addressParts = (addressInfo.split(':')[1] || '').split(';');
|
||||
const country = addressParts.slice(-1, addressParts.length).join(' ').replaceAll('\\n', '\n').replaceAll(
|
||||
'\\,',
|
||||
',',
|
||||
);
|
||||
const postalCode = addressParts.slice(-2, addressParts.length - 1).join(' ').replaceAll('\\n', '\n').replaceAll(
|
||||
'\\,',
|
||||
',',
|
||||
);
|
||||
const state = addressParts.slice(-3, addressParts.length - 2).join(' ').replaceAll('\\n', '\n').replaceAll(
|
||||
'\\,',
|
||||
',',
|
||||
);
|
||||
const city = addressParts.slice(-4, addressParts.length - 3).join(' ').replaceAll('\\n', '\n').replaceAll(
|
||||
'\\,',
|
||||
',',
|
||||
);
|
||||
const line1 = addressParts.slice(-5, addressParts.length - 4).join(' ').replaceAll('\\n', '\n').replaceAll(
|
||||
'\\,',
|
||||
',',
|
||||
);
|
||||
const line2 = addressParts.slice(-6, addressParts.length - 5).join(' ').replaceAll('\\n', '\n').replaceAll(
|
||||
'\\,',
|
||||
',',
|
||||
);
|
||||
|
||||
const label = ((addressInfo.split(':')[0] || '').split('TYPE=')[1] || 'home').replaceAll(';', '').replaceAll(
|
||||
'\\n',
|
||||
'\n',
|
||||
).replaceAll('\\,', ',');
|
||||
|
||||
if (!country && !postalCode && !state && !city && !line2 && !line1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const address: ContactAddress = {
|
||||
label,
|
||||
line_1: line1,
|
||||
line_2: line2,
|
||||
city,
|
||||
state,
|
||||
postal_code: postalCode,
|
||||
country,
|
||||
};
|
||||
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
addresses: [...(partialContact.extra?.addresses || []), address],
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.includes('PHOTO;')) {
|
||||
const photoInfo = line.split('PHOTO;')[1] || '';
|
||||
const photoUrl = photoInfo.split(':')[1];
|
||||
const photoMediaTypeInfo = photoInfo.split(':')[0];
|
||||
let photoMediaType = photoMediaTypeInfo.split('TYPE=')[1] || '';
|
||||
|
||||
if (!photoMediaType) {
|
||||
photoMediaType = 'image/jpeg';
|
||||
}
|
||||
|
||||
if (!photoMediaType.startsWith('image/')) {
|
||||
photoMediaType = `image/${photoMediaType.toLowerCase()}`;
|
||||
}
|
||||
|
||||
if (!photoUrl) {
|
||||
continue;
|
||||
}
|
||||
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
photo_mediatype: photoMediaType,
|
||||
photo_url: photoUrl,
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.includes('TEL;')) {
|
||||
const phoneInfo = line.split('TEL;')[1] || '';
|
||||
const phoneNumber = phoneInfo.split(':')[1] || '';
|
||||
const name = (phoneInfo.split(':')[0].split('TYPE=')[1] || 'home').replaceAll(';', '');
|
||||
|
||||
if (!phoneNumber) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const field: ContactField = {
|
||||
name,
|
||||
value: phoneNumber,
|
||||
type: 'phone',
|
||||
};
|
||||
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
fields: [...(partialContact.extra?.fields || []), field],
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.includes('EMAIL;')) {
|
||||
const emailInfo = line.split('EMAIL;')[1] || '';
|
||||
const emailAddress = emailInfo.split(':')[1] || '';
|
||||
const name = (emailInfo.split(':')[0].split('TYPE=')[1] || 'home').replaceAll(';', '');
|
||||
|
||||
if (!emailAddress) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const field: ContactField = {
|
||||
name,
|
||||
value: emailAddress,
|
||||
type: 'email',
|
||||
};
|
||||
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
fields: [...(partialContact.extra?.fields || []), field],
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return partialContacts;
|
||||
}
|
||||
294
lib/utils_test.ts
Normal file
294
lib/utils_test.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { assertEquals } from 'std/assert/assert_equals.ts';
|
||||
import {
|
||||
convertFormDataToObject,
|
||||
convertObjectToDavXml,
|
||||
convertObjectToFormData,
|
||||
escapeHtml,
|
||||
generateHash,
|
||||
generateRandomCode,
|
||||
splitArrayInChunks,
|
||||
validateEmail,
|
||||
validateUrl,
|
||||
} from './utils.ts';
|
||||
|
||||
Deno.test('that escapeHtml works', () => {
|
||||
const tests: { input: string; expected: string }[] = [
|
||||
{
|
||||
input: '<a href="https://brunobernardino.com">URL</a>',
|
||||
expected: '<a href="https://brunobernardino.com">URL</a>',
|
||||
},
|
||||
{
|
||||
input: "\"><img onerror='alert(1)' />",
|
||||
expected: '"><img onerror='alert(1)' />',
|
||||
},
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
const output = escapeHtml(test.input);
|
||||
assertEquals(output, test.expected);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('that generateRandomCode works', () => {
|
||||
const tests: { length: number }[] = [
|
||||
{
|
||||
length: 6,
|
||||
},
|
||||
{
|
||||
length: 7,
|
||||
},
|
||||
{
|
||||
length: 8,
|
||||
},
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
const output = generateRandomCode(test.length);
|
||||
assertEquals(output.length, test.length);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('that splitArrayInChunks works', () => {
|
||||
const tests: { input: { array: { number: number }[]; chunkLength: number }; expected: { number: number }[][] }[] = [
|
||||
{
|
||||
input: {
|
||||
array: [
|
||||
{ number: 1 },
|
||||
{ number: 2 },
|
||||
{ number: 3 },
|
||||
{ number: 4 },
|
||||
{ number: 5 },
|
||||
{ number: 6 },
|
||||
],
|
||||
chunkLength: 2,
|
||||
},
|
||||
expected: [
|
||||
[{ number: 1 }, { number: 2 }],
|
||||
[{ number: 3 }, { number: 4 }],
|
||||
[{ number: 5 }, { number: 6 }],
|
||||
],
|
||||
},
|
||||
{
|
||||
input: {
|
||||
array: [
|
||||
{ number: 1 },
|
||||
{ number: 2 },
|
||||
{ number: 3 },
|
||||
{ number: 4 },
|
||||
{ number: 5 },
|
||||
],
|
||||
chunkLength: 2,
|
||||
},
|
||||
expected: [
|
||||
[{ number: 1 }, { number: 2 }],
|
||||
[{ number: 3 }, { number: 4 }],
|
||||
[{ number: 5 }],
|
||||
],
|
||||
},
|
||||
{
|
||||
input: {
|
||||
array: [
|
||||
{ number: 1 },
|
||||
{ number: 2 },
|
||||
{ number: 3 },
|
||||
{ number: 4 },
|
||||
{ number: 5 },
|
||||
{ number: 6 },
|
||||
],
|
||||
chunkLength: 3,
|
||||
},
|
||||
expected: [
|
||||
[{ number: 1 }, { number: 2 }, { number: 3 }],
|
||||
[{ number: 4 }, { number: 5 }, { number: 6 }],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
const output = splitArrayInChunks(
|
||||
test.input.array,
|
||||
test.input.chunkLength,
|
||||
);
|
||||
assertEquals(output, test.expected);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('that generateHash works', async () => {
|
||||
const tests: { input: { value: string; algorithm: string }; expected: string }[] = [
|
||||
{
|
||||
input: {
|
||||
value: 'password',
|
||||
algorithm: 'SHA-256',
|
||||
},
|
||||
expected: '5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8',
|
||||
},
|
||||
{
|
||||
input: {
|
||||
value: '123456',
|
||||
algorithm: 'SHA-256',
|
||||
},
|
||||
expected: '8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92',
|
||||
},
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
const output = await generateHash(test.input.value, test.input.algorithm);
|
||||
assertEquals(output, test.expected);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('that validateEmail works', () => {
|
||||
const tests: { email: string; expected: boolean }[] = [
|
||||
{ email: 'user@example.com', expected: true },
|
||||
{ email: 'u@e.c', expected: true },
|
||||
{ email: 'user@example.', expected: false },
|
||||
{ email: '@example.com', expected: false },
|
||||
{ email: 'user@example.', expected: false },
|
||||
{ email: 'ABC', expected: false },
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
const result = validateEmail(test.email);
|
||||
assertEquals(result, test.expected);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('that validateUrl works', () => {
|
||||
const tests: { url: string; expected: boolean }[] = [
|
||||
{ url: 'https://bewcloud.com', expected: true },
|
||||
{ url: 'ftp://something', expected: true },
|
||||
{ url: 'http', expected: false },
|
||||
{ url: 'https://', expected: false },
|
||||
{ url: 'http://a', expected: true },
|
||||
{ url: 'ABC', expected: false },
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
const result = validateUrl(test.url);
|
||||
assertEquals(result, test.expected);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('that convertFormDataToObject works', () => {
|
||||
const formData1 = new FormData();
|
||||
formData1.append('user', '1');
|
||||
formData1.append('is_real', 'false');
|
||||
formData1.append('tags', 'one');
|
||||
formData1.append('tags', 'two');
|
||||
|
||||
const formData2 = new FormData();
|
||||
formData2.append('user', '2');
|
||||
formData2.append('is_real', 'true');
|
||||
formData2.append('tags', 'one');
|
||||
formData2.append('empty', '');
|
||||
|
||||
const tests: { input: FormData; expected: Record<string, any> }[] = [
|
||||
{
|
||||
input: formData1,
|
||||
expected: {
|
||||
user: '1',
|
||||
is_real: 'false',
|
||||
// tags: ['one', 'two'],
|
||||
tags: 'two', // NOTE: This is a limitation of the simple logic, but it should ideally be the array above
|
||||
},
|
||||
},
|
||||
{
|
||||
input: formData2,
|
||||
expected: {
|
||||
user: '2',
|
||||
is_real: 'true',
|
||||
tags: 'one',
|
||||
empty: '',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
const output = convertFormDataToObject(test.input);
|
||||
assertEquals(output, test.expected);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('that convertObjectToFormData works', () => {
|
||||
const formData1 = new FormData();
|
||||
formData1.append('user', '1');
|
||||
formData1.append('is_real', 'false');
|
||||
formData1.append('tags', 'one');
|
||||
// formData1.append('tags', 'two');// NOTE: This is a limitation of the simple logic, but it should ideally be an array below
|
||||
|
||||
const formData2 = new FormData();
|
||||
formData2.append('user', '2');
|
||||
formData2.append('is_real', 'true');
|
||||
formData2.append('tags', 'one');
|
||||
formData2.append('empty', '');
|
||||
|
||||
const tests: { input: Record<string, any>; expected: FormData }[] = [
|
||||
{
|
||||
input: {
|
||||
user: '1',
|
||||
is_real: 'false',
|
||||
tags: 'one',
|
||||
},
|
||||
expected: formData1,
|
||||
},
|
||||
{
|
||||
input: {
|
||||
user: '2',
|
||||
is_real: 'true',
|
||||
tags: 'one',
|
||||
empty: '',
|
||||
},
|
||||
expected: formData2,
|
||||
},
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
const output = convertObjectToFormData(test.input);
|
||||
assertEquals(convertFormDataToObject(output), convertFormDataToObject(test.expected));
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('that convertObjectToDavXml works', () => {
|
||||
const tests: { input: Record<string, any>; expected: string }[] = [
|
||||
{
|
||||
input: {
|
||||
url: 'https://bewcloud.com',
|
||||
},
|
||||
expected: `<url>https://bewcloud.com</url>`,
|
||||
},
|
||||
{
|
||||
input: {
|
||||
a: 'Website',
|
||||
a_attributes: {
|
||||
href: 'https://bewcloud.com',
|
||||
target: '_blank',
|
||||
},
|
||||
},
|
||||
expected: `<a href="https://bewcloud.com" target="_blank">Website</a>`,
|
||||
},
|
||||
{
|
||||
input: {
|
||||
article: {
|
||||
p: [
|
||||
{
|
||||
strong: 'Indeed',
|
||||
},
|
||||
{
|
||||
i: {},
|
||||
},
|
||||
'Mighty!',
|
||||
],
|
||||
},
|
||||
article_attributes: {
|
||||
class: 'center',
|
||||
},
|
||||
},
|
||||
expected: `<article class="center"><p><strong>Indeed</strong></p>\n<p><i /></p>\n<p>Mighty!</p></article>`,
|
||||
},
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
const result = convertObjectToDavXml(test.input);
|
||||
assertEquals(result, test.expected);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user