Make it public!
This commit is contained in:
7
routes/.well-known/carddav.tsx
Normal file
7
routes/.well-known/carddav.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
// Nextcloud/ownCloud mimicry
|
||||
export function handler(): Response {
|
||||
return new Response('Redirecting...', {
|
||||
status: 307,
|
||||
headers: { Location: '/dav' },
|
||||
});
|
||||
}
|
||||
38
routes/_404.tsx
Normal file
38
routes/_404.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Head } from 'fresh/runtime.ts';
|
||||
import { PageProps } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export default function Error404({ state }: PageProps<Data, FreshContextState>) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>404 - Page not found</title>
|
||||
</Head>
|
||||
<main>
|
||||
<section class='max-w-screen-md mx-auto flex flex-col items-center justify-center'>
|
||||
{!state.user
|
||||
? (
|
||||
<>
|
||||
<img
|
||||
class='my-6'
|
||||
src='/images/logo-white.svg'
|
||||
width='250'
|
||||
height='50'
|
||||
alt='the bewCloud logo: a stylized logo'
|
||||
/>
|
||||
<h1>404 - Page not found</h1>
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
<p class='my-4'>
|
||||
The page you were looking for doesn"t exist.
|
||||
</p>
|
||||
<a href='/'>Go back home</a>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
29
routes/_app.tsx
Normal file
29
routes/_app.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { PageProps } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { defaultDescription, defaultTitle } from '/lib/utils.ts';
|
||||
import Header from '/components/Header.tsx';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export default function App({ route, Component, state }: PageProps<Data, FreshContextState>) {
|
||||
return (
|
||||
<html class='h-full bg-slate-800'>
|
||||
<head>
|
||||
<meta charset='utf-8' />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
|
||||
<title>{defaultTitle}</title>
|
||||
<meta name='description' content={defaultDescription} />
|
||||
<meta name='author' content='Bruno Bernardino' />
|
||||
<meta property='og:title' content={defaultTitle} />
|
||||
<link rel='icon' href='/images/favicon.png' type='image/png' />
|
||||
<link rel='apple-touch-icon' href='/images/favicon.png' />
|
||||
<link rel='stylesheet' href='/styles.css' />
|
||||
</head>
|
||||
<body class='h-full'>
|
||||
<Header route={route} user={state.user} />
|
||||
<Component />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
73
routes/_middleware.tsx
Normal file
73
routes/_middleware.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { FreshContext } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { getDataFromRequest } from '/lib/auth.ts';
|
||||
|
||||
export const handler = [
|
||||
async function handleCors(request: Request, context: FreshContext<FreshContextState>) {
|
||||
if (request.method == 'OPTIONS') {
|
||||
const response = new Response(null, {
|
||||
status: 204,
|
||||
});
|
||||
const origin = request.headers.get('Origin') || '*';
|
||||
const headers = response.headers;
|
||||
headers.set('Access-Control-Allow-Origin', origin);
|
||||
headers.set('Access-Control-Allow-Methods', 'DELETE');
|
||||
return response;
|
||||
}
|
||||
|
||||
const origin = request.headers.get('Origin') || '*';
|
||||
const response = await context.next();
|
||||
const headers = response.headers;
|
||||
|
||||
headers.set('Access-Control-Allow-Origin', origin);
|
||||
headers.set('Access-Control-Allow-Credentials', 'true');
|
||||
headers.set(
|
||||
'Access-Control-Allow-Headers',
|
||||
'Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With',
|
||||
);
|
||||
headers.set(
|
||||
'Access-Control-Allow-Methods',
|
||||
'POST, OPTIONS, GET, PUT, DELETE',
|
||||
);
|
||||
headers.set(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'self'; child-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'",
|
||||
);
|
||||
headers.set('X-Frame-Options', 'DENY');
|
||||
headers.set('X-Content-Type-Options', 'nosniff');
|
||||
headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
async function handleContextState(request: Request, context: FreshContext<FreshContextState>) {
|
||||
const { user, session } = (await getDataFromRequest(request)) || {};
|
||||
|
||||
if (user) {
|
||||
context.state.user = user;
|
||||
}
|
||||
|
||||
if (session) {
|
||||
context.state.session = session;
|
||||
}
|
||||
|
||||
const response = await context.next();
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
async function handleLogging(request: Request, context: FreshContext<FreshContextState>) {
|
||||
const response = await context.next();
|
||||
|
||||
console.info(`${new Date().toISOString()} - ${request.method} ${request.url} [${response.status}]`);
|
||||
if (request.url.includes('/dav/')) {
|
||||
console.info(`Request`, request.headers);
|
||||
console.info((await request.clone().text()) || '<No Body>');
|
||||
console.info(`Response`, response.headers);
|
||||
console.info(`Status`, response.status);
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
];
|
||||
41
routes/api/contacts/add.tsx
Normal file
41
routes/api/contacts/add.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { Contact, FreshContextState } from '/lib/types.ts';
|
||||
import { createContact, getContacts } from '/lib/data/contacts.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
page: number;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
contacts: Pick<Contact, 'id' | 'first_name' | 'last_name'>[];
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const requestBody = await request.clone().json() as RequestBody;
|
||||
|
||||
if (requestBody.firstName) {
|
||||
const contact = await createContact(context.state.user.id, requestBody.firstName, requestBody.lastName);
|
||||
|
||||
if (!contact) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
const contacts = await getContacts(context.state.user.id, requestBody.page - 1);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, contacts };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
42
routes/api/contacts/delete.tsx
Normal file
42
routes/api/contacts/delete.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { Contact, FreshContextState } from '/lib/types.ts';
|
||||
import { deleteContact, getContact, getContacts } from '/lib/data/contacts.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
contactId: string;
|
||||
page: number;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
contacts: Pick<Contact, 'id' | 'first_name' | 'last_name'>[];
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const requestBody = await request.clone().json() as RequestBody;
|
||||
|
||||
if (requestBody.contactId) {
|
||||
const contact = await getContact(requestBody.contactId, context.state.user.id);
|
||||
|
||||
if (!contact) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
await deleteContact(requestBody.contactId, context.state.user.id);
|
||||
}
|
||||
|
||||
const contacts = await getContacts(context.state.user.id, requestBody.page - 1);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, contacts };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
27
routes/api/contacts/get.tsx
Normal file
27
routes/api/contacts/get.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { Contact, FreshContextState } from '/lib/types.ts';
|
||||
import { getAllContacts } from '/lib/data/contacts.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
contacts: Contact[];
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const contacts = await getAllContacts(context.state.user.id);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, contacts };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
60
routes/api/contacts/import.tsx
Normal file
60
routes/api/contacts/import.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { Contact, FreshContextState } from '/lib/types.ts';
|
||||
import { concurrentPromises } from '/lib/utils.ts';
|
||||
import { createContact, getContacts, updateContact } from '/lib/data/contacts.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
partialContacts: Partial<Contact>[];
|
||||
page: number;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
contacts: Pick<Contact, 'id' | 'first_name' | 'last_name'>[];
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const requestBody = await request.clone().json() as RequestBody;
|
||||
|
||||
if (requestBody.partialContacts) {
|
||||
if (requestBody.partialContacts.length === 0) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
await concurrentPromises(
|
||||
requestBody.partialContacts.map((partialContact) => async () => {
|
||||
if (partialContact.first_name) {
|
||||
const contact = await createContact(
|
||||
context.state.user!.id,
|
||||
partialContact.first_name,
|
||||
partialContact.last_name || '',
|
||||
);
|
||||
|
||||
const parsedExtra = JSON.stringify(partialContact.extra || {});
|
||||
|
||||
if (parsedExtra !== '{}') {
|
||||
contact.extra = partialContact.extra!;
|
||||
|
||||
await updateContact(contact);
|
||||
}
|
||||
}
|
||||
}),
|
||||
5,
|
||||
);
|
||||
}
|
||||
|
||||
const contacts = await getContacts(context.state.user.id, requestBody.page - 1);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, contacts };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
40
routes/api/dashboard/save-links.tsx
Normal file
40
routes/api/dashboard/save-links.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { DashboardLink, FreshContextState } from '/lib/types.ts';
|
||||
import { getDashboardByUserId, updateDashboard } from '/lib/data/dashboard.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
links: DashboardLink[];
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const userDashboard = await getDashboardByUserId(context.state.user.id);
|
||||
|
||||
if (!userDashboard) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const requestBody = await request.clone().json() as RequestBody;
|
||||
|
||||
if (typeof requestBody.links !== 'undefined') {
|
||||
userDashboard.data.links = requestBody.links;
|
||||
|
||||
await updateDashboard(userDashboard);
|
||||
}
|
||||
|
||||
const responseBody: ResponseBody = { success: true };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
40
routes/api/dashboard/save-notes.tsx
Normal file
40
routes/api/dashboard/save-notes.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { getDashboardByUserId, updateDashboard } from '/lib/data/dashboard.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const userDashboard = await getDashboardByUserId(context.state.user.id);
|
||||
|
||||
if (!userDashboard) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const requestBody = await request.clone().json() as RequestBody;
|
||||
|
||||
if (typeof requestBody.notes !== 'undefined' && userDashboard.data.notes !== requestBody.notes) {
|
||||
userDashboard.data.notes = requestBody.notes;
|
||||
|
||||
await updateDashboard(userDashboard);
|
||||
}
|
||||
|
||||
const responseBody: ResponseBody = { success: true };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
42
routes/api/news/add-feed.tsx
Normal file
42
routes/api/news/add-feed.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState, NewsFeed } from '/lib/types.ts';
|
||||
import { createNewsFeed, getNewsFeeds } from '/lib/data/news.ts';
|
||||
import { fetchNewArticles } from '/crons/news.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
feedUrl: string;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
newFeeds: NewsFeed[];
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const requestBody = await request.clone().json() as RequestBody;
|
||||
|
||||
if (requestBody.feedUrl) {
|
||||
const newFeed = await createNewsFeed(context.state.user.id, requestBody.feedUrl);
|
||||
|
||||
if (!newFeed) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
await fetchNewArticles();
|
||||
|
||||
const newFeeds = await getNewsFeeds(context.state.user.id);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, newFeeds };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
41
routes/api/news/delete-feed.tsx
Normal file
41
routes/api/news/delete-feed.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState, NewsFeed } from '/lib/types.ts';
|
||||
import { deleteNewsFeed, getNewsFeed, getNewsFeeds } from '/lib/data/news.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
feedId: string;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
newFeeds: NewsFeed[];
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const requestBody = await request.clone().json() as RequestBody;
|
||||
|
||||
if (requestBody.feedId) {
|
||||
const newsFeed = await getNewsFeed(requestBody.feedId, context.state.user.id);
|
||||
|
||||
if (!newsFeed) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
await deleteNewsFeed(requestBody.feedId);
|
||||
}
|
||||
|
||||
const newFeeds = await getNewsFeeds(context.state.user.id);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, newFeeds };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
46
routes/api/news/import-feeds.tsx
Normal file
46
routes/api/news/import-feeds.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState, NewsFeed } from '/lib/types.ts';
|
||||
import { concurrentPromises } from '/lib/utils.ts';
|
||||
import { createNewsFeed, getNewsFeeds } from '/lib/data/news.ts';
|
||||
import { fetchNewArticles } from '/crons/news.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
feedUrls: string[];
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
newFeeds: NewsFeed[];
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const requestBody = await request.clone().json() as RequestBody;
|
||||
|
||||
if (requestBody.feedUrls) {
|
||||
if (requestBody.feedUrls.length === 0) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
await concurrentPromises(
|
||||
requestBody.feedUrls.map((feedUrl) => () => createNewsFeed(context.state.user!.id, feedUrl)),
|
||||
5,
|
||||
);
|
||||
}
|
||||
|
||||
await fetchNewArticles();
|
||||
|
||||
const newFeeds = await getNewsFeeds(context.state.user.id);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, newFeeds };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
44
routes/api/news/mark-read.tsx
Normal file
44
routes/api/news/mark-read.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { getNewsArticle, markAllArticlesRead, updateNewsArticle } from '/lib/data/news.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
articleId: string;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const requestBody = await request.clone().json() as RequestBody;
|
||||
|
||||
if (requestBody.articleId) {
|
||||
if (requestBody.articleId === 'all') {
|
||||
await markAllArticlesRead(context.state.user.id);
|
||||
} else {
|
||||
const article = await getNewsArticle(requestBody.articleId, context.state.user.id);
|
||||
|
||||
if (!article) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
article.is_read = true;
|
||||
|
||||
await updateNewsArticle(article);
|
||||
}
|
||||
}
|
||||
|
||||
const responseBody: ResponseBody = { success: true };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
36
routes/api/news/refresh-articles.tsx
Normal file
36
routes/api/news/refresh-articles.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState, NewsFeedArticle } from '/lib/types.ts';
|
||||
import { getNewsArticles, getNewsFeeds } from '/lib/data/news.ts';
|
||||
import { fetchNewArticles } from '/crons/news.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
newArticles: NewsFeedArticle[];
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const newsFeeds = await getNewsFeeds(context.state.user.id);
|
||||
|
||||
if (!newsFeeds.length) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
await fetchNewArticles(true);
|
||||
|
||||
const newArticles = await getNewsArticles(context.state.user.id);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, newArticles };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
48
routes/contacts.tsx
Normal file
48
routes/contacts.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||
|
||||
import { Contact, FreshContextState } from '/lib/types.ts';
|
||||
import { getContacts, getContactsCount, searchContacts, searchContactsCount } from '/lib/data/contacts.ts';
|
||||
import Contacts from '/islands/contacts/Contacts.tsx';
|
||||
|
||||
interface Data {
|
||||
userContacts: Pick<Contact, 'id' | 'first_name' | 'last_name'>[];
|
||||
page: number;
|
||||
contactsCount: number;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async GET(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||
}
|
||||
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
|
||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||
const search = searchParams.get('search') || undefined;
|
||||
|
||||
const userContacts = search
|
||||
? await searchContacts(search, context.state.user.id, page - 1)
|
||||
: await getContacts(context.state.user.id, page - 1);
|
||||
|
||||
const contactsCount = search
|
||||
? await searchContactsCount(search, context.state.user.id)
|
||||
: await getContactsCount(context.state.user.id);
|
||||
|
||||
return await context.render({ userContacts, page, contactsCount, search });
|
||||
},
|
||||
};
|
||||
|
||||
export default function ContactsPage({ data }: PageProps<Data, FreshContextState>) {
|
||||
return (
|
||||
<main>
|
||||
<Contacts
|
||||
initialContacts={data?.userContacts || []}
|
||||
page={data?.page || 1}
|
||||
contactsCount={data?.contactsCount || 0}
|
||||
search={data?.search || ''}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
204
routes/contacts/[contactId].tsx
Normal file
204
routes/contacts/[contactId].tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||
|
||||
import { Contact, ContactAddress, ContactField, FreshContextState } from '/lib/types.ts';
|
||||
import { convertFormDataToObject } from '/lib/utils.ts';
|
||||
import { getContact, updateContact } from '/lib/data/contacts.ts';
|
||||
import { getFormDataField, getFormDataFieldArray } from '/lib/form-utils.tsx';
|
||||
import ViewContact, { formFields } from '/islands/contacts/ViewContact.tsx';
|
||||
|
||||
interface Data {
|
||||
contact: Contact;
|
||||
error?: string;
|
||||
notice?: string;
|
||||
formData: Record<string, any>;
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async GET(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||
}
|
||||
|
||||
const { contactId } = context.params;
|
||||
|
||||
const contact = await getContact(contactId, context.state.user.id);
|
||||
|
||||
if (!contact) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
return await context.render({ contact, formData: {} });
|
||||
},
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||
}
|
||||
|
||||
const { contactId } = context.params;
|
||||
|
||||
const contact = await getContact(contactId, context.state.user.id);
|
||||
|
||||
if (!contact) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
contact.extra.name_title = getFormDataField(formData, 'name_title') || undefined;
|
||||
contact.first_name = getFormDataField(formData, 'first_name');
|
||||
contact.last_name = getFormDataField(formData, 'last_name');
|
||||
contact.extra.middle_names = getFormDataField(formData, 'middle_names').split(' ').map((name) =>
|
||||
(name || '').trim()
|
||||
).filter(Boolean);
|
||||
if (contact.extra.middle_names.length === 0) {
|
||||
contact.extra.middle_names = undefined;
|
||||
}
|
||||
contact.extra.birthday = getFormDataField(formData, 'birthday') || undefined;
|
||||
contact.extra.nickname = getFormDataField(formData, 'nickname') || undefined;
|
||||
contact.extra.organization = getFormDataField(formData, 'organization') || undefined;
|
||||
contact.extra.role = getFormDataField(formData, 'role') || undefined;
|
||||
contact.extra.photo_url = getFormDataField(formData, 'photo_url') || undefined;
|
||||
contact.extra.photo_mediatype = contact.extra.photo_url
|
||||
? `image/${contact.extra.photo_url.split('.').slice(-1, 1).join('').toLowerCase()}`
|
||||
: undefined;
|
||||
contact.extra.notes = getFormDataField(formData, 'notes') || undefined;
|
||||
|
||||
contact.extra.fields = [];
|
||||
|
||||
// Phones
|
||||
const phoneNumbers = getFormDataFieldArray(formData, 'phone_numbers');
|
||||
const phoneLabels = getFormDataFieldArray(formData, 'phone_labels');
|
||||
|
||||
for (const [index, phoneNumber] of phoneNumbers.entries()) {
|
||||
if (phoneNumber.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const field: ContactField = {
|
||||
name: phoneLabels[index] || 'Home',
|
||||
value: phoneNumber.trim(),
|
||||
type: 'phone',
|
||||
};
|
||||
|
||||
contact.extra.fields.push(field);
|
||||
}
|
||||
|
||||
// Emails
|
||||
const emailAddresses = getFormDataFieldArray(formData, 'email_addresses');
|
||||
const emailLabels = getFormDataFieldArray(formData, 'email_labels');
|
||||
|
||||
for (const [index, emailAddress] of emailAddresses.entries()) {
|
||||
if (emailAddress.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const field: ContactField = {
|
||||
name: emailLabels[index] || 'Home',
|
||||
value: emailAddress.trim(),
|
||||
type: 'email',
|
||||
};
|
||||
|
||||
contact.extra.fields.push(field);
|
||||
}
|
||||
|
||||
// URLs
|
||||
const urlAddresses = getFormDataFieldArray(formData, 'url_addresses');
|
||||
const urlLabels = getFormDataFieldArray(formData, 'url_labels');
|
||||
|
||||
for (const [index, urlAddress] of urlAddresses.entries()) {
|
||||
if (urlAddress.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const field: ContactField = {
|
||||
name: urlLabels[index] || 'Home',
|
||||
value: urlAddress.trim(),
|
||||
type: 'url',
|
||||
};
|
||||
|
||||
contact.extra.fields.push(field);
|
||||
}
|
||||
|
||||
// Others
|
||||
const otherValues = getFormDataFieldArray(formData, 'other_values');
|
||||
const otherLabels = getFormDataFieldArray(formData, 'other_labels');
|
||||
|
||||
for (const [index, otherValue] of otherValues.entries()) {
|
||||
if (otherValue.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const field: ContactField = {
|
||||
name: otherLabels[index] || 'Home',
|
||||
value: otherValue.trim(),
|
||||
type: 'other',
|
||||
};
|
||||
|
||||
contact.extra.fields.push(field);
|
||||
}
|
||||
|
||||
contact.extra.addresses = [];
|
||||
|
||||
// Addresses
|
||||
const addressLine1s = getFormDataFieldArray(formData, 'address_line_1s');
|
||||
const addressLine2s = getFormDataFieldArray(formData, 'address_line_2s');
|
||||
const addressCities = getFormDataFieldArray(formData, 'address_cities');
|
||||
const addressPostalCodes = getFormDataFieldArray(formData, 'address_postal_codes');
|
||||
const addressStates = getFormDataFieldArray(formData, 'address_states');
|
||||
const addressCountries = getFormDataFieldArray(formData, 'address_countries');
|
||||
const addressLabels = getFormDataFieldArray(formData, 'address_labels');
|
||||
|
||||
for (const [index, addressLine1] of addressLine1s.entries()) {
|
||||
if (addressLine1.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const address: ContactAddress = {
|
||||
label: addressLabels[index] || 'Home',
|
||||
line_1: addressLine1.trim(),
|
||||
line_2: addressLine2s[index] || undefined,
|
||||
city: addressCities[index] || undefined,
|
||||
postal_code: addressPostalCodes[index] || undefined,
|
||||
state: addressStates[index] || undefined,
|
||||
country: addressCountries[index] || undefined,
|
||||
};
|
||||
|
||||
contact.extra.addresses.push(address);
|
||||
}
|
||||
|
||||
try {
|
||||
if (!contact.first_name) {
|
||||
throw new Error(`First name is required.`);
|
||||
}
|
||||
|
||||
formFields(contact).forEach((field) => {
|
||||
if (field.required) {
|
||||
const value = formData.get(field.name);
|
||||
|
||||
if (!value) {
|
||||
throw new Error(`${field.label} is required`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await updateContact(contact);
|
||||
|
||||
return await context.render({
|
||||
contact,
|
||||
notice: 'Contact updated successfully!',
|
||||
formData: convertFormDataToObject(formData),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return await context.render({ contact, error: error.toString(), formData: convertFormDataToObject(formData) });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default function ContactsPage({ data }: PageProps<Data, FreshContextState>) {
|
||||
return (
|
||||
<main>
|
||||
<ViewContact initialContact={data.contact} formData={data.formData} error={data.error} notice={data.notice} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
38
routes/dashboard.tsx
Normal file
38
routes/dashboard.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||
|
||||
import { Dashboard, FreshContextState } from '/lib/types.ts';
|
||||
import { createDashboard, getDashboardByUserId } from '/lib/data/dashboard.ts';
|
||||
import Notes from '/islands/dashboard/Notes.tsx';
|
||||
import Links from '/islands/dashboard/Links.tsx';
|
||||
|
||||
interface Data {
|
||||
userDashboard: Dashboard;
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async GET(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||
}
|
||||
|
||||
let userDashboard = await getDashboardByUserId(context.state.user.id);
|
||||
|
||||
if (!userDashboard) {
|
||||
userDashboard = await createDashboard(context.state.user.id);
|
||||
}
|
||||
|
||||
return await context.render({ userDashboard });
|
||||
},
|
||||
};
|
||||
|
||||
export default function Dashboard({ data }: PageProps<Data, FreshContextState>) {
|
||||
const initialNotes = data?.userDashboard?.data?.notes || 'Jot down some notes here.';
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Links initialLinks={data?.userDashboard?.data?.links || []} />
|
||||
|
||||
<Notes initialNotes={initialNotes} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
124
routes/dav/addressbooks.tsx
Normal file
124
routes/dav/addressbooks.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Handler } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml } from '/lib/utils.ts';
|
||||
import { createSessionCookie } from '/lib/auth.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
interface DavResponse {
|
||||
'd:href': string;
|
||||
'd:propstat': {
|
||||
'd:prop': {
|
||||
'd:resourcetype'?: {
|
||||
'd:collection': {};
|
||||
'card:addressbook'?: {};
|
||||
};
|
||||
'd:displayname'?: string;
|
||||
'd:getetag'?: string;
|
||||
'd:current-user-principal'?: {
|
||||
'd:href': string;
|
||||
};
|
||||
'd:principal-URL'?: {};
|
||||
'card:addressbook-home-set'?: {};
|
||||
};
|
||||
'd:status': string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface DavMultiStatusResponse {
|
||||
'd:multistatus': {
|
||||
'd:response': DavResponse[];
|
||||
};
|
||||
'd:multistatus_attributes': {
|
||||
'xmlns:d': string;
|
||||
'xmlns:s': string;
|
||||
'xmlns:card': string;
|
||||
'xmlns:oc': string;
|
||||
'xmlns:nc': string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ResponseBody extends DavMultiStatusResponse {}
|
||||
|
||||
export const handler: Handler<Data, FreshContextState> = (request, context) => {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', {
|
||||
status: 401,
|
||||
headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' },
|
||||
});
|
||||
}
|
||||
|
||||
if (request.method === 'GET') {
|
||||
return new Response('This is the WebDAV interface. It can only be accessed by WebDAV clients.');
|
||||
}
|
||||
|
||||
if (request.method !== 'PROPFIND' && request.method !== 'REPORT') {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const responseBody: ResponseBody = {
|
||||
'd:multistatus': {
|
||||
'd:response': [
|
||||
{
|
||||
'd:href': '/dav/addressbooks/',
|
||||
'd:propstat': [{
|
||||
'd:prop': {
|
||||
'd:resourcetype': {
|
||||
'd:collection': {},
|
||||
},
|
||||
'd:current-user-principal': {
|
||||
'd:href': '/dav/principals/',
|
||||
},
|
||||
},
|
||||
'd:status': 'HTTP/1.1 200 OK',
|
||||
}, {
|
||||
'd:prop': { 'd:principal-URL': {}, 'card:addressbook-home-set': {} },
|
||||
'd:status': 'HTTP/1.1 404 Not Found',
|
||||
}],
|
||||
},
|
||||
{
|
||||
'd:href': '/dav/addressbooks/contacts/',
|
||||
'd:propstat': [{
|
||||
'd:prop': {
|
||||
'd:resourcetype': {
|
||||
'd:collection': {},
|
||||
'card:addressbook': {},
|
||||
},
|
||||
'd:displayname': 'Contacts',
|
||||
'd:getetag': escapeHtml(`"${context.state.user.extra.contacts_revision || 'new'}"`),
|
||||
'd:current-user-principal': {
|
||||
'd:href': '/dav/principals/',
|
||||
},
|
||||
},
|
||||
'd:status': 'HTTP/1.1 200 OK',
|
||||
}, {
|
||||
'd:prop': { 'd:principal-URL': {}, 'card:addressbook-home-set': {} },
|
||||
'd:status': 'HTTP/1.1 404 Not Found',
|
||||
}],
|
||||
},
|
||||
],
|
||||
},
|
||||
'd:multistatus_attributes': {
|
||||
'xmlns:d': 'DAV:',
|
||||
'xmlns:s': 'http://sabredav.org/ns',
|
||||
'xmlns:card': 'urn:ietf:params:xml:ns:carddav',
|
||||
'xmlns:oc': 'http://owncloud.org/ns',
|
||||
'xmlns:nc': 'http://nextcloud.org/ns',
|
||||
},
|
||||
};
|
||||
|
||||
const response = new Response(convertObjectToDavXml(responseBody, true), {
|
||||
headers: {
|
||||
'content-type': 'application/xml; charset=utf-8',
|
||||
'dav': DAV_RESPONSE_HEADER,
|
||||
},
|
||||
status: 207,
|
||||
});
|
||||
|
||||
if (context.state.session) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return createSessionCookie(request, context.state.user, response, true);
|
||||
};
|
||||
249
routes/dav/addressbooks/contacts.tsx
Normal file
249
routes/dav/addressbooks/contacts.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import { Handler } from 'fresh/server.ts';
|
||||
import { parse } from 'xml/mod.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import {
|
||||
buildRFC822Date,
|
||||
convertObjectToDavXml,
|
||||
DAV_RESPONSE_HEADER,
|
||||
escapeHtml,
|
||||
escapeXml,
|
||||
formatContactToVCard,
|
||||
} from '/lib/utils.ts';
|
||||
import { getAllContacts } from '/lib/data/contacts.ts';
|
||||
import { createSessionCookie } from '/lib/auth.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
interface DavResponse {
|
||||
'd:href': string;
|
||||
'd:propstat': {
|
||||
'd:prop': {
|
||||
'd:resourcetype'?: {
|
||||
'd:collection'?: {};
|
||||
'card:addressbook'?: {};
|
||||
};
|
||||
'd:displayname'?: string | {};
|
||||
'card:address-data'?: string;
|
||||
'd:getlastmodified'?: string | {};
|
||||
'd:getetag'?: string | {};
|
||||
'd:getcontenttype'?: string | {};
|
||||
'd:getcontentlength'?: number | {};
|
||||
'd:creationdate'?: string | {};
|
||||
'card:addressbook-description'?: string | {};
|
||||
'cs:getctag'?: {};
|
||||
'd:current-user-privilege-set'?: {
|
||||
'd:privilege': {
|
||||
'd:write-properties'?: {};
|
||||
'd:write'?: {};
|
||||
'd:write-content'?: {};
|
||||
'd:unlock'?: {};
|
||||
'd:bind'?: {};
|
||||
'd:unbind'?: {};
|
||||
'd:write-acl'?: {};
|
||||
'd:read'?: {};
|
||||
'd:read-acl'?: {};
|
||||
'd:read-current-user-privilege-set'?: {};
|
||||
}[];
|
||||
};
|
||||
};
|
||||
'd:status': string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface DavMultiStatusResponse {
|
||||
'd:multistatus': {
|
||||
'd:response': DavResponse[];
|
||||
};
|
||||
'd:multistatus_attributes': {
|
||||
'xmlns:d': string;
|
||||
'xmlns:s': string;
|
||||
'xmlns:card': string;
|
||||
'xmlns:oc': string;
|
||||
'xmlns:nc': string;
|
||||
'xmlns:cs': string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ResponseBody extends DavMultiStatusResponse {}
|
||||
|
||||
export const handler: Handler<Data, FreshContextState> = async (request, context) => {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', {
|
||||
status: 401,
|
||||
headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' },
|
||||
});
|
||||
}
|
||||
|
||||
if (request.method === 'GET') {
|
||||
return new Response('This is the WebDAV interface. It can only be accessed by WebDAV clients.');
|
||||
}
|
||||
|
||||
if (request.method !== 'PROPFIND' && request.method !== 'REPORT') {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const contacts = await getAllContacts(context.state.user.id);
|
||||
|
||||
const requestBody = (await request.clone().text()).toLowerCase();
|
||||
|
||||
let includeVCard = false;
|
||||
let includeCollection = true;
|
||||
const includePrivileges = requestBody.includes('current-user-privilege-set');
|
||||
|
||||
const filterContactIds = new Set<string>();
|
||||
|
||||
try {
|
||||
const parsedDocument = parse(requestBody);
|
||||
|
||||
const multiGetRequest = (parsedDocument['addressbook-multiget'] || parsedDocument['r:addressbook-multiget'] ||
|
||||
parsedDocument['f:addressbook-multiget'] || parsedDocument['d:addressbook-multiget'] ||
|
||||
parsedDocument['r:addressbook-query'] ||
|
||||
parsedDocument['card:addressbook-multiget'] || parsedDocument['card:addressbook-query']) as
|
||||
| Record<string, any>
|
||||
| undefined;
|
||||
|
||||
includeVCard = Boolean(multiGetRequest);
|
||||
|
||||
const requestedHrefs: string[] = (multiGetRequest && (multiGetRequest['href'] || multiGetRequest['d:href'])) || [];
|
||||
|
||||
includeCollection = requestedHrefs.length === 0;
|
||||
|
||||
for (const requestedHref of requestedHrefs) {
|
||||
const userVCard = requestedHref.split('/').slice(-1).join('');
|
||||
const [userId] = userVCard.split('.vcf');
|
||||
|
||||
if (userId) {
|
||||
filterContactIds.add(userId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to parse XML`, error);
|
||||
}
|
||||
|
||||
const filteredContacts = filterContactIds.size > 0
|
||||
? contacts.filter((contact) => filterContactIds.has(contact.id))
|
||||
: contacts;
|
||||
|
||||
const parsedContacts = filteredContacts.map((contact) => {
|
||||
const parsedContact: DavResponse = {
|
||||
'd:href': `/dav/addressbooks/contacts/${contact.id}.vcf`,
|
||||
'd:propstat': [{
|
||||
'd:prop': {
|
||||
'd:getlastmodified': buildRFC822Date(contact.updated_at.toISOString()),
|
||||
'd:getetag': escapeHtml(`"${contact.revision}"`),
|
||||
'd:getcontenttype': 'text/vcard; charset=utf-8',
|
||||
'd:resourcetype': {},
|
||||
},
|
||||
'd:status': 'HTTP/1.1 200 OK',
|
||||
}, {
|
||||
'd:prop': {
|
||||
'd:displayname': {},
|
||||
'd:getcontentlength': {},
|
||||
'd:creationdate': {},
|
||||
'card:addressbook-description': {},
|
||||
'cs:getctag': {},
|
||||
},
|
||||
'd:status': 'HTTP/1.1 404 Not Found',
|
||||
}],
|
||||
};
|
||||
|
||||
if (includeVCard) {
|
||||
parsedContact['d:propstat'][0]['d:prop']['card:address-data'] = escapeXml(formatContactToVCard([contact]));
|
||||
}
|
||||
|
||||
if (includePrivileges) {
|
||||
parsedContact['d:propstat'][0]['d:prop']['d:current-user-privilege-set'] = {
|
||||
'd:privilege': [
|
||||
{ 'd:write-properties': {} },
|
||||
{ 'd:write': {} },
|
||||
{ 'd:write-content': {} },
|
||||
{ 'd:unlock': {} },
|
||||
{ 'd:bind': {} },
|
||||
{ 'd:unbind': {} },
|
||||
{ 'd:write-acl': {} },
|
||||
{ 'd:read': {} },
|
||||
{ 'd:read-acl': {} },
|
||||
{ 'd:read-current-user-privilege-set': {} },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return parsedContact;
|
||||
});
|
||||
|
||||
const responseBody: ResponseBody = {
|
||||
'd:multistatus': {
|
||||
'd:response': [
|
||||
...parsedContacts,
|
||||
],
|
||||
},
|
||||
'd:multistatus_attributes': {
|
||||
'xmlns:d': 'DAV:',
|
||||
'xmlns:s': 'http://sabredav.org/ns',
|
||||
'xmlns:card': 'urn:ietf:params:xml:ns:carddav',
|
||||
'xmlns:oc': 'http://owncloud.org/ns',
|
||||
'xmlns:nc': 'http://nextcloud.org/ns',
|
||||
'xmlns:cs': 'http://calendarserver.org/ns/',
|
||||
},
|
||||
};
|
||||
|
||||
if (includeCollection) {
|
||||
const collectionResponse: DavResponse = {
|
||||
'd:href': '/dav/addressbooks/contacts/',
|
||||
'd:propstat': [{
|
||||
'd:prop': {
|
||||
'd:resourcetype': {
|
||||
'd:collection': {},
|
||||
'card:addressbook': {},
|
||||
},
|
||||
'd:displayname': 'Contacts',
|
||||
'd:getetag': escapeHtml(`"${context.state.user.extra.contacts_revision || 'new'}"`),
|
||||
},
|
||||
'd:status': 'HTTP/1.1 200 OK',
|
||||
}, {
|
||||
'd:prop': {
|
||||
'd:getlastmodified': {},
|
||||
'd:getcontenttype': {},
|
||||
'd:getcontentlength': {},
|
||||
'd:creationdate': {},
|
||||
'card:addressbook-description': {},
|
||||
'cs:getctag': {},
|
||||
},
|
||||
'd:status': 'HTTP/1.1 404 Not Found',
|
||||
}],
|
||||
};
|
||||
|
||||
if (includePrivileges) {
|
||||
collectionResponse['d:propstat'][0]['d:prop']['d:current-user-privilege-set'] = {
|
||||
'd:privilege': [
|
||||
{ 'd:write-properties': {} },
|
||||
{ 'd:write': {} },
|
||||
{ 'd:write-content': {} },
|
||||
{ 'd:unlock': {} },
|
||||
{ 'd:bind': {} },
|
||||
{ 'd:unbind': {} },
|
||||
{ 'd:write-acl': {} },
|
||||
{ 'd:read': {} },
|
||||
{ 'd:read-acl': {} },
|
||||
{ 'd:read-current-user-privilege-set': {} },
|
||||
],
|
||||
};
|
||||
}
|
||||
responseBody['d:multistatus']['d:response'].unshift(collectionResponse);
|
||||
}
|
||||
|
||||
const response = new Response(convertObjectToDavXml(responseBody, true), {
|
||||
headers: {
|
||||
'content-type': 'application/xml; charset=utf-8',
|
||||
'dav': DAV_RESPONSE_HEADER,
|
||||
},
|
||||
status: 207,
|
||||
});
|
||||
|
||||
if (context.state.session) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return createSessionCookie(request, context.state.user, response, true);
|
||||
};
|
||||
228
routes/dav/addressbooks/contacts/[contactId].vcf.tsx
Normal file
228
routes/dav/addressbooks/contacts/[contactId].vcf.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { Handler } from 'fresh/server.ts';
|
||||
import { parse } from 'xml/mod.ts';
|
||||
|
||||
import { Contact, FreshContextState } from '/lib/types.ts';
|
||||
import {
|
||||
buildRFC822Date,
|
||||
convertObjectToDavXml,
|
||||
DAV_RESPONSE_HEADER,
|
||||
escapeHtml,
|
||||
escapeXml,
|
||||
formatContactToVCard,
|
||||
parseVCardFromTextContents,
|
||||
} from '/lib/utils.ts';
|
||||
import { createContact, deleteContact, getContact, updateContact } from '/lib/data/contacts.ts';
|
||||
import { createSessionCookie } from '/lib/auth.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
interface DavResponse {
|
||||
'd:href': string;
|
||||
'd:propstat': {
|
||||
'd:prop': {
|
||||
'd:resourcetype': {
|
||||
'd:collection'?: {};
|
||||
'card:addressbook'?: {};
|
||||
};
|
||||
'card:address-data'?: string;
|
||||
'd:getlastmodified'?: string;
|
||||
'd:getetag'?: string;
|
||||
'd:getcontenttype'?: string;
|
||||
};
|
||||
'd:status': string;
|
||||
};
|
||||
}
|
||||
|
||||
interface DavMultiStatusResponse {
|
||||
'd:multistatus': {
|
||||
'd:response': DavResponse[];
|
||||
};
|
||||
'd:multistatus_attributes': {
|
||||
'xmlns:d': string;
|
||||
'xmlns:s': string;
|
||||
'xmlns:card': string;
|
||||
'xmlns:oc': string;
|
||||
'xmlns:nc': string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ResponseBody extends DavMultiStatusResponse {}
|
||||
|
||||
export const handler: Handler<Data, FreshContextState> = async (request, context) => {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', {
|
||||
status: 401,
|
||||
headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' },
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
request.method !== 'PROPFIND' && request.method !== 'REPORT' && request.method !== 'GET' &&
|
||||
request.method !== 'PUT' && request.method !== 'DELETE'
|
||||
) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const { contactId } = context.params;
|
||||
|
||||
let contact: Contact | null = null;
|
||||
|
||||
try {
|
||||
contact = await getContact(contactId, context.state.user.id);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
if (!contact) {
|
||||
if (request.method === 'PUT') {
|
||||
const requestBody = await request.clone().text();
|
||||
|
||||
const [partialContact] = parseVCardFromTextContents(requestBody);
|
||||
|
||||
if (partialContact.first_name) {
|
||||
const newContact = await createContact(
|
||||
context.state.user.id,
|
||||
partialContact.first_name,
|
||||
partialContact.last_name || '',
|
||||
);
|
||||
|
||||
// Use the sent id for the UID
|
||||
if (!partialContact.extra?.uid) {
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
uid: contactId,
|
||||
};
|
||||
}
|
||||
|
||||
newContact.extra = partialContact.extra!;
|
||||
|
||||
await updateContact(newContact);
|
||||
|
||||
contact = await getContact(newContact.id, context.state.user.id);
|
||||
|
||||
return new Response('Created', { status: 201, headers: { 'etag': `"${contact.revision}"` } });
|
||||
}
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
if (request.method === 'DELETE') {
|
||||
const clientRevision = request.headers.get('if-match') || request.headers.get('etag');
|
||||
|
||||
// Don't update outdated data
|
||||
if (clientRevision && clientRevision !== `"${contact.revision}"`) {
|
||||
return new Response(null, { status: 204, headers: { 'etag': `"${contact.revision}"` } });
|
||||
}
|
||||
|
||||
await deleteContact(contactId, context.state.user.id);
|
||||
|
||||
return new Response(null, { status: 202 });
|
||||
}
|
||||
|
||||
if (request.method === 'PUT') {
|
||||
const clientRevision = request.headers.get('if-match') || request.headers.get('etag');
|
||||
|
||||
// Don't update outdated data
|
||||
if (clientRevision && clientRevision !== `"${contact.revision}"`) {
|
||||
return new Response(null, { status: 204, headers: { 'etag': `"${contact.revision}"` } });
|
||||
}
|
||||
|
||||
const requestBody = await request.clone().text();
|
||||
|
||||
const [partialContact] = parseVCardFromTextContents(requestBody);
|
||||
|
||||
contact = {
|
||||
...contact,
|
||||
...partialContact,
|
||||
};
|
||||
|
||||
await updateContact(contact);
|
||||
|
||||
contact = await getContact(contactId, context.state.user.id);
|
||||
|
||||
return new Response(null, { status: 204, headers: { 'etag': `"${contact.revision}"` } });
|
||||
}
|
||||
|
||||
if (request.method === 'GET') {
|
||||
// Set a UID if there isn't one
|
||||
if (!contact.extra.uid) {
|
||||
contact.extra.uid = crypto.randomUUID();
|
||||
await updateContact(contact);
|
||||
|
||||
contact = await getContact(contactId, context.state.user.id);
|
||||
}
|
||||
|
||||
const response = new Response(formatContactToVCard([contact]), {
|
||||
status: 200,
|
||||
headers: { 'etag': `"${contact.revision}"` },
|
||||
});
|
||||
|
||||
if (context.state.session) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return createSessionCookie(request, context.state.user, response, true);
|
||||
}
|
||||
|
||||
const requestBody = (await request.clone().text()).toLowerCase();
|
||||
|
||||
let includeVCard = false;
|
||||
|
||||
try {
|
||||
const parsedDocument = parse(requestBody);
|
||||
|
||||
const multiGetRequest = (parsedDocument['r:addressbook-multiget'] || parsedDocument['r:addressbook-query'] ||
|
||||
parsedDocument['card:addressbook-multiget'] || parsedDocument['card:addressbook-query']) as
|
||||
| Record<string, any>
|
||||
| undefined;
|
||||
|
||||
includeVCard = Boolean(multiGetRequest);
|
||||
} catch (error) {
|
||||
console.error(`Failed to parse XML`, error);
|
||||
}
|
||||
|
||||
const parsedContact: DavResponse = {
|
||||
'd:href': `/dav/addressbooks/contacts/${contact.id}.vcf`,
|
||||
'd:propstat': {
|
||||
'd:prop': {
|
||||
'd:getlastmodified': buildRFC822Date(contact.updated_at.toISOString()),
|
||||
'd:getetag': escapeHtml(`"${contact.revision}"`),
|
||||
'd:getcontenttype': 'text/vcard; charset=utf-8',
|
||||
'd:resourcetype': {},
|
||||
},
|
||||
'd:status': 'HTTP/1.1 200 OK',
|
||||
},
|
||||
};
|
||||
|
||||
if (includeVCard) {
|
||||
parsedContact['d:propstat']['d:prop']['card:address-data'] = escapeXml(formatContactToVCard([contact]));
|
||||
}
|
||||
|
||||
const responseBody: ResponseBody = {
|
||||
'd:multistatus': {
|
||||
'd:response': [parsedContact],
|
||||
},
|
||||
'd:multistatus_attributes': {
|
||||
'xmlns:d': 'DAV:',
|
||||
'xmlns:s': 'http://sabredav.org/ns',
|
||||
'xmlns:card': 'urn:ietf:params:xml:ns:carddav',
|
||||
'xmlns:oc': 'http://owncloud.org/ns',
|
||||
'xmlns:nc': 'http://nextcloud.org/ns',
|
||||
},
|
||||
};
|
||||
|
||||
const response = new Response(convertObjectToDavXml(responseBody, true), {
|
||||
headers: {
|
||||
'content-type': 'application/xml; charset=utf-8',
|
||||
'dav': DAV_RESPONSE_HEADER,
|
||||
},
|
||||
status: 207,
|
||||
});
|
||||
|
||||
if (context.state.session) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return createSessionCookie(request, context.state.user, response, true);
|
||||
};
|
||||
91
routes/dav/files.tsx
Normal file
91
routes/dav/files.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Handler } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml } from '/lib/utils.ts';
|
||||
import { createSessionCookie } from '/lib/auth.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
interface DavResponse {
|
||||
'd:href': string;
|
||||
'd:propstat': {
|
||||
'd:prop': {
|
||||
'd:resourcetype': {
|
||||
'd:collection'?: {};
|
||||
};
|
||||
'd:getlastmodified': string;
|
||||
'd:getetag': string;
|
||||
'd:getcontentlength'?: number;
|
||||
'd:getcontenttype'?: string;
|
||||
};
|
||||
'd:status': string;
|
||||
};
|
||||
}
|
||||
|
||||
interface DavMultiStatusResponse {
|
||||
'd:multistatus': {
|
||||
'd:response': DavResponse[];
|
||||
};
|
||||
'd:multistatus_attributes': { 'xmlns:d': string; 'xmlns:s': string; 'xmlns:oc': string; 'xmlns:nc': string };
|
||||
}
|
||||
|
||||
interface ResponseBody extends DavMultiStatusResponse {}
|
||||
|
||||
export const handler: Handler<Data, FreshContextState> = (request, context) => {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', {
|
||||
status: 401,
|
||||
headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' },
|
||||
});
|
||||
}
|
||||
|
||||
if (request.method === 'GET') {
|
||||
return new Response('This is the WebDAV interface. It can only be accessed by WebDAV clients.');
|
||||
}
|
||||
|
||||
if (request.method !== 'PROPFIND') {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
// TODO: List directories and files in root
|
||||
|
||||
const responseBody: ResponseBody = {
|
||||
'd:multistatus': {
|
||||
'd:response': [
|
||||
{
|
||||
'd:href': '/dav/files/',
|
||||
'd:propstat': {
|
||||
'd:prop': {
|
||||
'd:resourcetype': {
|
||||
'd:collection': {},
|
||||
},
|
||||
'd:getlastmodified': buildRFC822Date('2020-01-01'),
|
||||
'd:getetag': escapeHtml(`"fake"`),
|
||||
},
|
||||
'd:status': 'HTTP/1.1 200 OK',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'd:multistatus_attributes': {
|
||||
'xmlns:d': 'DAV:',
|
||||
'xmlns:s': 'http://sabredav.org/ns',
|
||||
'xmlns:oc': 'http://owncloud.org/ns',
|
||||
'xmlns:nc': 'http://nextcloud.org/ns',
|
||||
},
|
||||
};
|
||||
|
||||
const response = new Response(convertObjectToDavXml(responseBody, true), {
|
||||
headers: {
|
||||
'content-type': 'application/xml; charset=utf-8',
|
||||
'dav': DAV_RESPONSE_HEADER,
|
||||
},
|
||||
status: 207,
|
||||
});
|
||||
|
||||
if (context.state.session) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return createSessionCookie(request, context.state.user, response, true);
|
||||
};
|
||||
118
routes/dav/index.tsx
Normal file
118
routes/dav/index.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Handler } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml } from '/lib/utils.ts';
|
||||
import { createSessionCookie } from '/lib/auth.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
interface DavResponse {
|
||||
'd:href': string;
|
||||
'd:propstat': {
|
||||
'd:prop': {
|
||||
'd:resourcetype': {
|
||||
'd:collection': {};
|
||||
};
|
||||
'd:current-user-principal'?: {
|
||||
'd:href': string;
|
||||
};
|
||||
'd:getlastmodified'?: string;
|
||||
'd:getetag'?: string;
|
||||
};
|
||||
'd:status': string;
|
||||
};
|
||||
}
|
||||
|
||||
interface DavMultiStatusResponse {
|
||||
'd:multistatus': {
|
||||
'd:response': DavResponse[];
|
||||
};
|
||||
'd:multistatus_attributes': { 'xmlns:d': string; 'xmlns:s': string; 'xmlns:oc': string; 'xmlns:nc': string };
|
||||
}
|
||||
|
||||
interface ResponseBody extends DavMultiStatusResponse {}
|
||||
|
||||
export const handler: Handler<Data, FreshContextState> = (request, context) => {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', {
|
||||
status: 401,
|
||||
headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' },
|
||||
});
|
||||
}
|
||||
|
||||
if (request.method === 'GET') {
|
||||
return new Response('This is the WebDAV interface. It can only be accessed by WebDAV clients.');
|
||||
}
|
||||
|
||||
if (request.method !== 'PROPFIND' && request.method !== 'REPORT') {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const responseBody: ResponseBody = {
|
||||
'd:multistatus': {
|
||||
'd:response': [
|
||||
{
|
||||
'd:href': '/dav/',
|
||||
'd:propstat': {
|
||||
'd:prop': {
|
||||
'd:resourcetype': {
|
||||
'd:collection': {},
|
||||
},
|
||||
},
|
||||
'd:status': 'HTTP/1.1 200 OK',
|
||||
},
|
||||
},
|
||||
{
|
||||
'd:href': '/dav/addressbooks/',
|
||||
'd:propstat': {
|
||||
'd:prop': {
|
||||
'd:resourcetype': {
|
||||
'd:collection': {},
|
||||
},
|
||||
'd:current-user-principal': {
|
||||
'd:href': '/dav/principals/',
|
||||
},
|
||||
},
|
||||
'd:status': 'HTTP/1.1 200 OK',
|
||||
},
|
||||
},
|
||||
{
|
||||
'd:href': '/dav/files/',
|
||||
'd:propstat': {
|
||||
'd:prop': {
|
||||
'd:resourcetype': {
|
||||
'd:collection': {},
|
||||
},
|
||||
'd:current-user-principal': {
|
||||
'd:href': '/dav/principals/',
|
||||
},
|
||||
'd:getlastmodified': buildRFC822Date('2020-01-01'),
|
||||
'd:getetag': escapeHtml(`"fake"`),
|
||||
},
|
||||
'd:status': 'HTTP/1.1 200 OK',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'd:multistatus_attributes': {
|
||||
'xmlns:d': 'DAV:',
|
||||
'xmlns:s': 'http://sabredav.org/ns',
|
||||
'xmlns:oc': 'http://owncloud.org/ns',
|
||||
'xmlns:nc': 'http://nextcloud.org/ns',
|
||||
},
|
||||
};
|
||||
|
||||
const response = new Response(convertObjectToDavXml(responseBody, true), {
|
||||
headers: {
|
||||
'content-type': 'application/xml; charset=utf-8',
|
||||
'dav': DAV_RESPONSE_HEADER,
|
||||
},
|
||||
status: 207,
|
||||
});
|
||||
|
||||
if (context.state.session) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return createSessionCookie(request, context.state.user, response, true);
|
||||
};
|
||||
128
routes/dav/principals.tsx
Normal file
128
routes/dav/principals.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { Handler } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { convertObjectToDavXml, DAV_RESPONSE_HEADER } from '/lib/utils.ts';
|
||||
import { createSessionCookie } from '/lib/auth.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
interface DavResponse {
|
||||
'd:href': string;
|
||||
'd:propstat': {
|
||||
'd:prop': {
|
||||
'd:resourcetype': {
|
||||
'd:collection'?: {};
|
||||
'd:principal': {};
|
||||
};
|
||||
'd:displayname'?: string;
|
||||
'card:addressbook-home-set'?: {
|
||||
'd:href': string;
|
||||
};
|
||||
'd:current-user-principal'?: {
|
||||
'd:href': string;
|
||||
};
|
||||
'd:principal-URL'?: {
|
||||
'd:href': string;
|
||||
};
|
||||
};
|
||||
'd:status': string;
|
||||
};
|
||||
}
|
||||
|
||||
interface DavMultiStatusResponse {
|
||||
'd:multistatus': {
|
||||
'd:response': DavResponse[];
|
||||
};
|
||||
'd:multistatus_attributes': {
|
||||
'xmlns:d': string;
|
||||
'xmlns:s': string;
|
||||
'xmlns:cal': string;
|
||||
'xmlns:cs': string;
|
||||
'xmlns:card': string;
|
||||
'xmlns:oc': string;
|
||||
'xmlns:nc': string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ResponseBody extends DavMultiStatusResponse {}
|
||||
|
||||
export const handler: Handler<Data, FreshContextState> = async (request, context) => {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', {
|
||||
status: 401,
|
||||
headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' },
|
||||
});
|
||||
}
|
||||
|
||||
if (request.method === 'GET') {
|
||||
return new Response('This is the WebDAV interface. It can only be accessed by WebDAV clients.');
|
||||
}
|
||||
|
||||
if (request.method !== 'PROPFIND' && request.method !== 'REPORT') {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const responseBody: ResponseBody = {
|
||||
'd:multistatus': {
|
||||
'd:response': [],
|
||||
},
|
||||
'd:multistatus_attributes': {
|
||||
'xmlns:d': 'DAV:',
|
||||
'xmlns:s': 'http://sabredav.org/ns',
|
||||
'xmlns:cal': 'urn:ietf:params:xml:ns:caldav',
|
||||
'xmlns:cs': 'http://calendarserver.org/ns/',
|
||||
'xmlns:card': 'urn:ietf:params:xml:ns:carddav',
|
||||
'xmlns:oc': 'http://owncloud.org/ns',
|
||||
'xmlns:nc': 'http://nextcloud.org/ns',
|
||||
},
|
||||
};
|
||||
|
||||
if (request.method === 'PROPFIND') {
|
||||
const propResponse: DavResponse = {
|
||||
'd:href': '/dav/principals/',
|
||||
'd:propstat': {
|
||||
'd:prop': {
|
||||
'd:resourcetype': {
|
||||
'd:collection': {},
|
||||
'd:principal': {},
|
||||
},
|
||||
'd:current-user-principal': {
|
||||
'd:href': '/dav/principals/',
|
||||
},
|
||||
'd:principal-URL': {
|
||||
'd:href': '/dav/principals/',
|
||||
},
|
||||
},
|
||||
'd:status': 'HTTP/1.1 200 OK',
|
||||
},
|
||||
};
|
||||
|
||||
const requestBody = (await request.clone().text()).toLowerCase();
|
||||
|
||||
if (requestBody.includes('displayname')) {
|
||||
propResponse['d:propstat']['d:prop']['d:displayname'] = `${context.state.user.email}`;
|
||||
}
|
||||
|
||||
if (requestBody.includes('addressbook-home-set')) {
|
||||
propResponse['d:propstat']['d:prop']['card:addressbook-home-set'] = {
|
||||
'd:href': `/dav/addressbooks/`,
|
||||
};
|
||||
}
|
||||
|
||||
responseBody['d:multistatus']['d:response'].push(propResponse);
|
||||
}
|
||||
|
||||
const response = new Response(convertObjectToDavXml(responseBody, true), {
|
||||
headers: {
|
||||
'content-type': 'application/xml; charset=utf-8',
|
||||
'dav': DAV_RESPONSE_HEADER,
|
||||
},
|
||||
status: 207,
|
||||
});
|
||||
|
||||
if (context.state.session) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return createSessionCookie(request, context.state.user, response, true);
|
||||
};
|
||||
15
routes/index.tsx
Normal file
15
routes/index.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
GET(request, context) {
|
||||
if (context.state.user) {
|
||||
return new Response('Redirect', { status: 303, headers: { 'Location': `/dashboard` } });
|
||||
}
|
||||
|
||||
return new Response('Redirect', { status: 303, headers: { 'Location': '/login' } });
|
||||
},
|
||||
};
|
||||
175
routes/login.tsx
Normal file
175
routes/login.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||
|
||||
import { generateHash, helpEmail, validateEmail } from '/lib/utils.ts';
|
||||
import { createSessionResponse, PASSWORD_SALT } from '/lib/auth.ts';
|
||||
import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx';
|
||||
import { createVerificationCode, getUserByEmail, updateUser, validateVerificationCode } from '/lib/data/user.ts';
|
||||
import { sendVerifyEmailEmail } from '/lib/providers/brevo.ts';
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
|
||||
interface Data {
|
||||
error?: string;
|
||||
notice?: string;
|
||||
email?: string;
|
||||
formData?: FormData;
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async GET(request, context) {
|
||||
if (context.state.user) {
|
||||
return new Response('Redirect', { status: 303, headers: { 'Location': `/` } });
|
||||
}
|
||||
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
|
||||
const formData = new FormData();
|
||||
let notice = '';
|
||||
let email = '';
|
||||
|
||||
if (searchParams.get('success') === 'signup') {
|
||||
email = searchParams.get('email') || '';
|
||||
formData.set('email', email);
|
||||
|
||||
notice = `You have received a code in your email. Use it to verify your email and login.`;
|
||||
}
|
||||
|
||||
return await context.render({ notice, email, formData });
|
||||
},
|
||||
async POST(request, context) {
|
||||
if (context.state.user) {
|
||||
return new Response('Redirect', { status: 303, headers: { 'Location': `/` } });
|
||||
}
|
||||
|
||||
const formData = await request.clone().formData();
|
||||
const email = getFormDataField(formData, 'email');
|
||||
|
||||
try {
|
||||
if (!validateEmail(email)) {
|
||||
throw new Error(`Invalid email.`);
|
||||
}
|
||||
|
||||
const password = getFormDataField(formData, 'password');
|
||||
|
||||
if (password.length < 6) {
|
||||
throw new Error(`Password is too short.`);
|
||||
}
|
||||
|
||||
const hashedPassword = await generateHash(`${password}:${PASSWORD_SALT}`, 'SHA-256');
|
||||
|
||||
const user = await getUserByEmail(email);
|
||||
|
||||
if (!user || user.hashed_password !== hashedPassword) {
|
||||
throw new Error('Email not found or invalid password.');
|
||||
}
|
||||
|
||||
if (!user.extra.is_email_verified) {
|
||||
const code = getFormDataField(formData, 'verification-code');
|
||||
|
||||
if (!code) {
|
||||
const verificationCode = await createVerificationCode(user, user.email, 'email');
|
||||
|
||||
await sendVerifyEmailEmail(user.email, verificationCode);
|
||||
|
||||
throw new Error('Email not verified. New code sent to verify your email.');
|
||||
} else {
|
||||
await validateVerificationCode(user, user.email, code, 'email');
|
||||
|
||||
user.extra.is_email_verified = true;
|
||||
|
||||
await updateUser(user);
|
||||
}
|
||||
}
|
||||
|
||||
return createSessionResponse(request, user, { urlToRedirectTo: `/` });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return await context.render({ error: error.toString(), email, formData });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function formFields(email?: string, showVerificationCode = false) {
|
||||
const fields: FormField[] = [
|
||||
{
|
||||
name: 'email',
|
||||
label: 'Email',
|
||||
type: 'email',
|
||||
placeholder: 'jane.doe@example.com',
|
||||
value: email || '',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'password',
|
||||
label: 'Password',
|
||||
type: 'password',
|
||||
placeholder: 'super-SECRET-passphrase',
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
if (showVerificationCode) {
|
||||
fields.push({
|
||||
name: 'verification-code',
|
||||
label: 'Verification Code',
|
||||
description: `The verification code to validate your email.`,
|
||||
type: 'text',
|
||||
placeholder: '000000',
|
||||
required: true,
|
||||
});
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
export default function Login({ data }: PageProps<Data, FreshContextState>) {
|
||||
return (
|
||||
<main>
|
||||
<section class='max-w-screen-md mx-auto flex flex-col items-center justify-center'>
|
||||
<h1 class='text-4xl mb-6'>
|
||||
Login
|
||||
</h1>
|
||||
{data?.error
|
||||
? (
|
||||
<section class='notification-error'>
|
||||
<h3>Failed to login!</h3>
|
||||
<p>{data?.error}</p>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
{data?.notice
|
||||
? (
|
||||
<section class='notification-success'>
|
||||
<h3>Verify your email!</h3>
|
||||
<p>{data?.notice}</p>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
|
||||
<form method='POST' class='mb-12'>
|
||||
{formFields(data?.email, data?.notice?.includes('verify your email')).map((field) =>
|
||||
generateFieldHtml(field, data?.formData || new FormData())
|
||||
)}
|
||||
<section class='flex justify-center mt-8 mb-4'>
|
||||
<button class='button' type='submit'>Login</button>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<h2 class='text-2xl mb-4 text-center'>Need an account?</h2>
|
||||
<p class='text-center mt-2 mb-6'>
|
||||
If you still don't have an account,{' '}
|
||||
<strong>
|
||||
<a href='/signup'>signup</a>
|
||||
</strong>.
|
||||
</p>
|
||||
|
||||
<h2 class='text-2xl mb-4 text-center'>Need help?</h2>
|
||||
<p class='text-center mt-2 mb-6'>
|
||||
If you're having any issues or have any questions,{' '}
|
||||
<strong>
|
||||
<a href={`mailto:${helpEmail}`}>please reach out</a>
|
||||
</strong>.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
16
routes/logout.tsx
Normal file
16
routes/logout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { logoutUser } from '/lib/auth.ts';
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async GET(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||
}
|
||||
|
||||
return await logoutUser(request);
|
||||
},
|
||||
};
|
||||
29
routes/news.tsx
Normal file
29
routes/news.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState, NewsFeedArticle } from '/lib/types.ts';
|
||||
import { getNewsArticles } from '/lib/data/news.ts';
|
||||
import Articles from '/islands/news/Articles.tsx';
|
||||
|
||||
interface Data {
|
||||
userArticles: NewsFeedArticle[];
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async GET(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||
}
|
||||
|
||||
const userArticles = await getNewsArticles(context.state.user.id);
|
||||
|
||||
return await context.render({ userArticles });
|
||||
},
|
||||
};
|
||||
|
||||
export default function News({ data }: PageProps<Data, FreshContextState>) {
|
||||
return (
|
||||
<main>
|
||||
<Articles initialArticles={data?.userArticles || []} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
29
routes/news/feeds.tsx
Normal file
29
routes/news/feeds.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState, NewsFeed } from '/lib/types.ts';
|
||||
import { getNewsFeeds } from '/lib/data/news.ts';
|
||||
import Feeds from '/islands/news/Feeds.tsx';
|
||||
|
||||
interface Data {
|
||||
userFeeds: NewsFeed[];
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async GET(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||
}
|
||||
|
||||
const userFeeds = await getNewsFeeds(context.state.user.id);
|
||||
|
||||
return await context.render({ userFeeds });
|
||||
},
|
||||
};
|
||||
|
||||
export default function FeedsPage({ data }: PageProps<Data, FreshContextState>) {
|
||||
return (
|
||||
<main>
|
||||
<Feeds initialFeeds={data?.userFeeds || []} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
7
routes/remote.php/[davRoute].tsx
Normal file
7
routes/remote.php/[davRoute].tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
// Nextcloud/ownCloud mimicry
|
||||
export function handler(): Response {
|
||||
return new Response('Redirecting...', {
|
||||
status: 307,
|
||||
headers: { Location: '/dav' },
|
||||
});
|
||||
}
|
||||
186
routes/settings.tsx
Normal file
186
routes/settings.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||
|
||||
import { Dashboard, FreshContextState } from '/lib/types.ts';
|
||||
import { PASSWORD_SALT } from '/lib/auth.ts';
|
||||
import {
|
||||
createVerificationCode,
|
||||
deleteUser,
|
||||
getUserByEmail,
|
||||
updateUser,
|
||||
validateVerificationCode,
|
||||
} from '/lib/data/user.ts';
|
||||
import { convertFormDataToObject, generateHash, validateEmail } from '/lib/utils.ts';
|
||||
import { getFormDataField } from '/lib/form-utils.tsx';
|
||||
import { sendVerifyEmailEmail } from '/lib/providers/brevo.ts';
|
||||
import Settings, { Action, actionWords } from '/islands/Settings.tsx';
|
||||
|
||||
interface Data {
|
||||
error?: {
|
||||
title: string;
|
||||
message: string;
|
||||
};
|
||||
notice?: {
|
||||
title: string;
|
||||
message: string;
|
||||
};
|
||||
formData: Record<string, any>;
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async GET(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||
}
|
||||
|
||||
return await context.render();
|
||||
},
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||
}
|
||||
|
||||
let action: Action = 'change-email';
|
||||
let errorTitle = '';
|
||||
let errorMessage = '';
|
||||
let successTitle = '';
|
||||
let successMessage = '';
|
||||
|
||||
const formData = await request.clone().formData();
|
||||
|
||||
try {
|
||||
const { user } = context.state;
|
||||
|
||||
action = getFormDataField(formData, 'action') as Action;
|
||||
|
||||
if (action !== 'change-email' && action !== 'verify-change-email') {
|
||||
formData.set('email', user.email);
|
||||
}
|
||||
|
||||
if ((action === 'change-email' || action === 'verify-change-email')) {
|
||||
const email = getFormDataField(formData, 'email');
|
||||
|
||||
if (!validateEmail(email)) {
|
||||
throw new Error(`Invalid email.`);
|
||||
}
|
||||
|
||||
if (email === user.email) {
|
||||
throw new Error(`New email is the same as the current email.`);
|
||||
}
|
||||
|
||||
const matchingUser = await getUserByEmail(email);
|
||||
|
||||
if (matchingUser) {
|
||||
throw new Error('Email is already in use.');
|
||||
}
|
||||
|
||||
if (action === 'change-email') {
|
||||
const verificationCode = await createVerificationCode(user, email, 'email');
|
||||
|
||||
await sendVerifyEmailEmail(email, verificationCode);
|
||||
|
||||
successTitle = 'Verify your email!';
|
||||
successMessage = 'You have received a code in your new email. Use it to verify it here.';
|
||||
} else {
|
||||
const code = getFormDataField(formData, 'verification-code');
|
||||
|
||||
await validateVerificationCode(user, email, code, 'email');
|
||||
|
||||
user.email = email;
|
||||
|
||||
await updateUser(user);
|
||||
|
||||
successTitle = 'Email updated!';
|
||||
successMessage = 'Email updated successfully.';
|
||||
}
|
||||
} else if (action === 'change-password') {
|
||||
const currentPassword = getFormDataField(formData, 'current-password');
|
||||
const newPassword = getFormDataField(formData, 'new-password');
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
throw new Error(`New password is too short`);
|
||||
}
|
||||
|
||||
const hashedCurrentPassword = await generateHash(`${currentPassword}:${PASSWORD_SALT}`, 'SHA-256');
|
||||
const hashedNewPassword = await generateHash(`${newPassword}:${PASSWORD_SALT}`, 'SHA-256');
|
||||
|
||||
if (user.hashed_password !== hashedCurrentPassword) {
|
||||
throw new Error('Invalid current password.');
|
||||
}
|
||||
|
||||
if (hashedCurrentPassword === hashedNewPassword) {
|
||||
throw new Error(`New password is the same as the current password.`);
|
||||
}
|
||||
|
||||
user.hashed_password = hashedNewPassword;
|
||||
|
||||
await updateUser(user);
|
||||
|
||||
successTitle = 'Password changed!';
|
||||
successMessage = 'Password changed successfully.';
|
||||
} else if (action === 'change-dav-password') {
|
||||
const newDavPassword = getFormDataField(formData, 'new-dav-password');
|
||||
|
||||
if (newDavPassword.length < 6) {
|
||||
throw new Error(`New DAV password is too short`);
|
||||
}
|
||||
|
||||
const hashedNewDavPassword = await generateHash(`${newDavPassword}:${PASSWORD_SALT}`, 'SHA-256');
|
||||
|
||||
if (user.extra.dav_hashed_password === hashedNewDavPassword) {
|
||||
throw new Error(`New DAV password is the same as the current password.`);
|
||||
}
|
||||
|
||||
user.extra.dav_hashed_password = hashedNewDavPassword;
|
||||
|
||||
await updateUser(user);
|
||||
|
||||
successTitle = 'DAV Password changed!';
|
||||
successMessage = 'DAV Password changed successfully.';
|
||||
} else if (action === 'delete-account') {
|
||||
const currentPassword = getFormDataField(formData, 'current-password');
|
||||
|
||||
const hashedCurrentPassword = await generateHash(`${currentPassword}:${PASSWORD_SALT}`, 'SHA-256');
|
||||
|
||||
if (user.hashed_password !== hashedCurrentPassword) {
|
||||
throw new Error('Invalid current password.');
|
||||
}
|
||||
|
||||
await deleteUser(user.id);
|
||||
|
||||
return new Response('Account deleted successfully', {
|
||||
status: 303,
|
||||
headers: { 'location': `/signup?success=delete` },
|
||||
});
|
||||
}
|
||||
|
||||
const notice = successTitle
|
||||
? {
|
||||
title: successTitle,
|
||||
message: successMessage,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return await context.render({
|
||||
notice,
|
||||
formData: convertFormDataToObject(formData),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
errorMessage = error.toString();
|
||||
errorTitle = `Failed to ${actionWords.get(action) || action}!`;
|
||||
|
||||
return await context.render({
|
||||
error: { title: errorTitle, message: errorMessage },
|
||||
formData: convertFormDataToObject(formData),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default function Dashboard({ data }: PageProps<Data, FreshContextState>) {
|
||||
return (
|
||||
<main>
|
||||
<Settings formData={data?.formData} error={data?.error} notice={data?.notice} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
155
routes/signup.tsx
Normal file
155
routes/signup.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||
|
||||
import { generateHash, helpEmail, validateEmail } from '/lib/utils.ts';
|
||||
import { PASSWORD_SALT } from '/lib/auth.ts';
|
||||
import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx';
|
||||
import { createUser, createVerificationCode, getUserByEmail } from '/lib/data/user.ts';
|
||||
import { sendVerifyEmailEmail } from '/lib/providers/brevo.ts';
|
||||
import { isSignupAllowed } from '/lib/config.ts';
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
|
||||
interface Data {
|
||||
error?: string;
|
||||
notice?: string;
|
||||
email?: string;
|
||||
formData?: FormData;
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async GET(request, context) {
|
||||
if (context.state.user) {
|
||||
return new Response('Redirect', { status: 303, headers: { 'Location': `/` } });
|
||||
}
|
||||
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
|
||||
let notice = '';
|
||||
|
||||
if (searchParams.get('success') === 'delete') {
|
||||
notice = `Your account and all its data has been deleted.`;
|
||||
}
|
||||
|
||||
return await context.render({ notice });
|
||||
},
|
||||
async POST(request, context) {
|
||||
if (context.state.user) {
|
||||
return new Response('Redirect', { status: 303, headers: { 'Location': `/` } });
|
||||
}
|
||||
|
||||
const formData = await request.clone().formData();
|
||||
const email = getFormDataField(formData, 'email');
|
||||
|
||||
try {
|
||||
if (!(await isSignupAllowed())) {
|
||||
throw new Error(`Signups are not allowed.`);
|
||||
}
|
||||
|
||||
if (!validateEmail(email)) {
|
||||
throw new Error(`Invalid email.`);
|
||||
}
|
||||
|
||||
const password = getFormDataField(formData, 'password');
|
||||
|
||||
if (password.length < 6) {
|
||||
throw new Error(`Password is too short.`);
|
||||
}
|
||||
|
||||
const existingUser = await getUserByEmail(email);
|
||||
|
||||
if (existingUser) {
|
||||
throw new Error('Email is already in use. Perhaps you want to login instead?');
|
||||
}
|
||||
|
||||
const hashedPassword = await generateHash(`${password}:${PASSWORD_SALT}`, 'SHA-256');
|
||||
|
||||
const user = await createUser(email, hashedPassword);
|
||||
|
||||
const verificationCode = await createVerificationCode(user, user.email, 'email');
|
||||
|
||||
await sendVerifyEmailEmail(user.email, verificationCode);
|
||||
|
||||
return new Response('Signup successful', {
|
||||
status: 303,
|
||||
headers: { 'location': `/login?success=signup&email=${encodeURIComponent(user.email)}` },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return await context.render({ error: error.toString(), email, formData });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function formFields(email?: string) {
|
||||
const fields: FormField[] = [
|
||||
{
|
||||
name: 'email',
|
||||
label: 'Email',
|
||||
description: `The email that will be used to login. A code will be sent to it.`,
|
||||
type: 'email',
|
||||
placeholder: 'jane.doe@example.com',
|
||||
value: email || '',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'password',
|
||||
label: 'Password',
|
||||
description: `The password that will be used to login.`,
|
||||
type: 'password',
|
||||
placeholder: 'super-SECRET-passphrase',
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
export default function Signup({ data }: PageProps<Data, FreshContextState>) {
|
||||
return (
|
||||
<main>
|
||||
<section class='max-w-screen-md mx-auto flex flex-col items-center justify-center'>
|
||||
<h1 class='text-4xl mb-6'>
|
||||
Signup
|
||||
</h1>
|
||||
{data?.error
|
||||
? (
|
||||
<section class='notification-error'>
|
||||
<h3>Failed to signup!</h3>
|
||||
<p>{data?.error}</p>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
{data?.notice
|
||||
? (
|
||||
<section class='notification-success'>
|
||||
<h3>Success!</h3>
|
||||
<p>{data?.notice}</p>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
|
||||
<form method='POST' class='mb-12'>
|
||||
{formFields(data?.email).map((field) => generateFieldHtml(field, data?.formData || new FormData()))}
|
||||
<section class='flex justify-center mt-8 mb-4'>
|
||||
<button class='button' type='submit'>Signup</button>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<h2 class='text-2xl mb-4 text-center'>Already have an account?</h2>
|
||||
<p class='text-center mt-2 mb-6'>
|
||||
If you already have an account,{' '}
|
||||
<strong>
|
||||
<a href='/login'>login</a>
|
||||
</strong>.
|
||||
</p>
|
||||
|
||||
<h2 class='text-2xl mb-4 text-center'>Need help?</h2>
|
||||
<p class='text-center mt-2 mb-6'>
|
||||
If you're having any issues or have any questions,{' '}
|
||||
<strong>
|
||||
<a href={`mailto:${helpEmail}`}>please reach out</a>
|
||||
</strong>.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user