Make it public!

This commit is contained in:
Bruno Bernardino
2024-03-16 08:40:24 +00:00
commit a5cafdddca
114 changed files with 9569 additions and 0 deletions

View 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
View 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
View 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
View 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;
},
];

View 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));
},
};

View 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));
},
};

View 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));
},
};

View 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));
},
};

View 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));
},
};

View 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));
},
};

View 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));
},
};

View 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));
},
};

View 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));
},
};

View 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));
},
};

View 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
View 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>
);
}

View 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
View 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
View 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);
};

View 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);
};

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>
);
}

View 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
View 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
View 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>
);
}