refactor: use tabs instead of spaces

This commit is contained in:
Sonny
2024-10-07 01:33:59 +02:00
parent f425decf2c
commit eea9732100
197 changed files with 5206 additions and 5209 deletions

View File

@@ -8,12 +8,12 @@ const ARROW_UP = 'ArrowUp';
const ARROW_DOWN = 'ArrowDown';
const KEYS = {
ARROW_DOWN,
ARROW_UP,
ESCAPE_KEY,
OPEN_CREATE_COLLECTION_KEY,
OPEN_CREATE_LINK_KEY,
OPEN_SEARCH_KEY,
ARROW_DOWN,
ARROW_UP,
ESCAPE_KEY,
OPEN_CREATE_COLLECTION_KEY,
OPEN_CREATE_LINK_KEY,
OPEN_SEARCH_KEY,
};
export default KEYS;

View File

@@ -1,8 +1,8 @@
const PATHS = {
AUTHOR: 'https://www.sonny.dev/',
REPO_GITHUB: 'https://github.com/Sonny93/my-links',
EXTENSION:
'https://chromewebstore.google.com/detail/mylinks/agkmlplihacolkakgeccnbhphnepphma',
AUTHOR: 'https://www.sonny.dev/',
REPO_GITHUB: 'https://github.com/Sonny93/my-links',
EXTENSION:
'https://chromewebstore.google.com/detail/mylinks/agkmlplihacolkakgeccnbhphnepphma',
} as const;
export default PATHS;

View File

@@ -6,41 +6,41 @@ import { inject } from '@adonisjs/core';
import type { HttpContext } from '@adonisjs/core/http';
class UserWithRelationCountDto {
constructor(private user: User) {}
constructor(private user: User) {}
toJson = () => ({
id: this.user.id,
email: this.user.email,
fullname: this.user.name,
avatarUrl: this.user.avatarUrl,
isAdmin: this.user.isAdmin,
createdAt: this.user.createdAt,
updatedAt: this.user.updatedAt,
count: {
link: Number(this.user.$extras.totalLinks),
collection: Number(this.user.$extras.totalCollections),
},
});
toJson = () => ({
id: this.user.id,
email: this.user.email,
fullname: this.user.name,
avatarUrl: this.user.avatarUrl,
isAdmin: this.user.isAdmin,
createdAt: this.user.createdAt,
updatedAt: this.user.updatedAt,
count: {
link: Number(this.user.$extras.totalLinks),
collection: Number(this.user.$extras.totalCollections),
},
});
}
@inject()
export default class AdminController {
constructor(
protected usersController: UsersController,
protected linksController: LinksController,
protected collectionsController: CollectionsController
) {}
constructor(
protected usersController: UsersController,
protected linksController: LinksController,
protected collectionsController: CollectionsController
) {}
async index({ inertia }: HttpContext) {
const users = await this.usersController.getAllUsersWithTotalRelations();
const linksCount = await this.linksController.getTotalLinksCount();
const collectionsCount =
await this.collectionsController.getTotalCollectionsCount();
async index({ inertia }: HttpContext) {
const users = await this.usersController.getAllUsersWithTotalRelations();
const linksCount = await this.linksController.getTotalLinksCount();
const collectionsCount =
await this.collectionsController.getTotalCollectionsCount();
return inertia.render('admin/dashboard', {
users: users.map((user) => new UserWithRelationCountDto(user).toJson()),
totalLinks: linksCount,
totalCollections: collectionsCount,
});
}
return inertia.render('admin/dashboard', {
users: users.map((user) => new UserWithRelationCountDto(user).toJson()),
totalLinks: linksCount,
totalCollections: collectionsCount,
});
}
}

View File

@@ -3,11 +3,11 @@ import { updateUserThemeValidator } from '#validators/user';
import type { HttpContext } from '@adonisjs/core/http';
export default class AppsController {
async updateUserTheme({ request, session, response }: HttpContext) {
const { preferDarkTheme } = await request.validateUsing(
updateUserThemeValidator
);
session.put(PREFER_DARK_THEME, preferDarkTheme);
return response.ok({ message: 'ok' });
}
async updateUserTheme({ request, session, response }: HttpContext) {
const { preferDarkTheme } = await request.validateUsing(
updateUserThemeValidator
);
session.put(PREFER_DARK_THEME, preferDarkTheme);
return response.ok({ message: 'ok' });
}
}

View File

@@ -1,143 +1,143 @@
import Collection from '#models/collection';
import User from '#models/user';
import {
createCollectionValidator,
deleteCollectionValidator,
updateCollectionValidator,
createCollectionValidator,
deleteCollectionValidator,
updateCollectionValidator,
} from '#validators/collection';
import type { HttpContext } from '@adonisjs/core/http';
import db from '@adonisjs/lucid/services/db';
export default class CollectionsController {
// Dashboard
async index({ auth, inertia, request, response }: HttpContext) {
const collections = await this.getCollectionsByAuthorId(auth.user!.id);
if (collections.length === 0) {
return response.redirectToNamedRoute('collection.create-form');
}
// Dashboard
async index({ auth, inertia, request, response }: HttpContext) {
const collections = await this.getCollectionsByAuthorId(auth.user!.id);
if (collections.length === 0) {
return response.redirectToNamedRoute('collection.create-form');
}
const activeCollectionId = Number(request.qs()?.collectionId ?? '');
const activeCollection = collections.find(
(c) => c.id === activeCollectionId
);
const activeCollectionId = Number(request.qs()?.collectionId ?? '');
const activeCollection = collections.find(
(c) => c.id === activeCollectionId
);
if (!activeCollection && !!activeCollectionId) {
return response.redirectToNamedRoute('dashboard');
}
if (!activeCollection && !!activeCollectionId) {
return response.redirectToNamedRoute('dashboard');
}
// TODO: Create DTOs
return inertia.render('dashboard', {
collections: collections.map((collection) => collection.serialize()),
activeCollection:
activeCollection?.serialize() || collections[0].serialize(),
});
}
// TODO: Create DTOs
return inertia.render('dashboard', {
collections: collections.map((collection) => collection.serialize()),
activeCollection:
activeCollection?.serialize() || collections[0].serialize(),
});
}
// Create collection form
async showCreatePage({ inertia, auth }: HttpContext) {
const collections = await this.getCollectionsByAuthorId(auth.user!.id);
return inertia.render('collections/create', {
disableHomeLink: collections.length === 0,
});
}
// Create collection form
async showCreatePage({ inertia, auth }: HttpContext) {
const collections = await this.getCollectionsByAuthorId(auth.user!.id);
return inertia.render('collections/create', {
disableHomeLink: collections.length === 0,
});
}
// Method called when creating a collection
async store({ request, response, auth }: HttpContext) {
const payload = await request.validateUsing(createCollectionValidator);
const collection = await Collection.create({
...payload,
authorId: auth.user?.id!,
});
return this.redirectToCollectionId(response, collection.id);
}
// Method called when creating a collection
async store({ request, response, auth }: HttpContext) {
const payload = await request.validateUsing(createCollectionValidator);
const collection = await Collection.create({
...payload,
authorId: auth.user?.id!,
});
return this.redirectToCollectionId(response, collection.id);
}
async showEditPage({ auth, request, inertia, response }: HttpContext) {
const collectionId = request.qs()?.collectionId;
if (!collectionId) {
return response.redirectToNamedRoute('dashboard');
}
async showEditPage({ auth, request, inertia, response }: HttpContext) {
const collectionId = request.qs()?.collectionId;
if (!collectionId) {
return response.redirectToNamedRoute('dashboard');
}
const collection = await this.getCollectionById(
collectionId,
auth.user!.id
);
return inertia.render('collections/edit', {
collection,
});
}
const collection = await this.getCollectionById(
collectionId,
auth.user!.id
);
return inertia.render('collections/edit', {
collection,
});
}
async update({ request, auth, response }: HttpContext) {
const { params, ...payload } = await request.validateUsing(
updateCollectionValidator
);
async update({ request, auth, response }: HttpContext) {
const { params, ...payload } = await request.validateUsing(
updateCollectionValidator
);
// Cant use validator (vinejs) custom rule 'cause its too generic,
// because we have to find a collection by identifier and
// check whether the current user is the author.
// https://vinejs.dev/docs/extend/custom_rules
await this.getCollectionById(params.id, auth.user!.id);
// Cant use validator (vinejs) custom rule 'cause its too generic,
// because we have to find a collection by identifier and
// check whether the current user is the author.
// https://vinejs.dev/docs/extend/custom_rules
await this.getCollectionById(params.id, auth.user!.id);
await Collection.updateOrCreate(
{
id: params.id,
},
payload
);
return this.redirectToCollectionId(response, params.id);
}
await Collection.updateOrCreate(
{
id: params.id,
},
payload
);
return this.redirectToCollectionId(response, params.id);
}
async showDeletePage({ auth, request, inertia, response }: HttpContext) {
const collectionId = request.qs()?.collectionId;
if (!collectionId) {
return response.redirectToNamedRoute('dashboard');
}
async showDeletePage({ auth, request, inertia, response }: HttpContext) {
const collectionId = request.qs()?.collectionId;
if (!collectionId) {
return response.redirectToNamedRoute('dashboard');
}
const collection = await this.getCollectionById(
collectionId,
auth.user!.id
);
return inertia.render('collections/delete', {
collection,
});
}
const collection = await this.getCollectionById(
collectionId,
auth.user!.id
);
return inertia.render('collections/delete', {
collection,
});
}
async delete({ request, auth, response }: HttpContext) {
const { params } = await request.validateUsing(deleteCollectionValidator);
const collection = await this.getCollectionById(params.id, auth.user!.id);
await collection.delete();
return response.redirectToNamedRoute('dashboard');
}
async delete({ request, auth, response }: HttpContext) {
const { params } = await request.validateUsing(deleteCollectionValidator);
const collection = await this.getCollectionById(params.id, auth.user!.id);
await collection.delete();
return response.redirectToNamedRoute('dashboard');
}
async getTotalCollectionsCount() {
const totalCount = await db.from('collections').count('* as total');
return Number(totalCount[0].total);
}
async getTotalCollectionsCount() {
const totalCount = await db.from('collections').count('* as total');
return Number(totalCount[0].total);
}
/**
* Get collection by id.
*
* /!\ Only return private collection (create by the current user)
*/
async getCollectionById(id: Collection['id'], userId: User['id']) {
return await Collection.query()
.where('id', id)
.andWhere('author_id', userId)
.firstOrFail();
}
/**
* Get collection by id.
*
* /!\ Only return private collection (create by the current user)
*/
async getCollectionById(id: Collection['id'], userId: User['id']) {
return await Collection.query()
.where('id', id)
.andWhere('author_id', userId)
.firstOrFail();
}
async getCollectionsByAuthorId(authorId: User['id']) {
return await Collection.query()
.where('author_id', authorId)
.orderBy('created_at')
.preload('links');
}
async getCollectionsByAuthorId(authorId: User['id']) {
return await Collection.query()
.where('author_id', authorId)
.orderBy('created_at')
.preload('links');
}
redirectToCollectionId(
response: HttpContext['response'],
collectionId: Collection['id']
) {
return response.redirectToNamedRoute('dashboard', {
qs: { collectionId },
});
}
redirectToCollectionId(
response: HttpContext['response'],
collectionId: Collection['id']
) {
return response.redirectToNamedRoute('dashboard', {
qs: { collectionId },
});
}
}

View File

@@ -5,156 +5,156 @@ import logger from '@adonisjs/core/services/logger';
import { parse } from 'node-html-parser';
interface Favicon {
buffer: Buffer;
url: string;
type: string;
size: number;
buffer: Buffer;
url: string;
type: string;
size: number;
}
export default class FaviconsController {
private userAgent =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0';
private relList = [
'icon',
'shortcut icon',
'apple-touch-icon',
'apple-touch-icon-precomposed',
'apple-touch-startup-image',
'mask-icon',
'fluid-icon',
];
private userAgent =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0';
private relList = [
'icon',
'shortcut icon',
'apple-touch-icon',
'apple-touch-icon-precomposed',
'apple-touch-startup-image',
'mask-icon',
'fluid-icon',
];
async index(ctx: HttpContext) {
const url = ctx.request.qs()?.url;
if (!url) {
throw new Error('Missing URL');
}
async index(ctx: HttpContext) {
const url = ctx.request.qs()?.url;
if (!url) {
throw new Error('Missing URL');
}
const cacheNs = cache.namespace('favicon');
const favicon = await cacheNs.getOrSet({
key: url,
ttl: '1h',
factory: () => this.tryGetFavicon(url),
});
return this.sendImage(ctx, favicon);
}
const cacheNs = cache.namespace('favicon');
const favicon = await cacheNs.getOrSet({
key: url,
ttl: '1h',
factory: () => this.tryGetFavicon(url),
});
return this.sendImage(ctx, favicon);
}
private async tryGetFavicon(url: string): Promise<Favicon> {
const faviconUrl = this.buildFaviconUrl(url, '/favicon.ico');
try {
return await this.fetchFavicon(faviconUrl);
} catch {
logger.debug(`Unable to retrieve favicon from ${faviconUrl}`);
}
private async tryGetFavicon(url: string): Promise<Favicon> {
const faviconUrl = this.buildFaviconUrl(url, '/favicon.ico');
try {
return await this.fetchFavicon(faviconUrl);
} catch {
logger.debug(`Unable to retrieve favicon from ${faviconUrl}`);
}
const documentText = await this.fetchDocumentText(url);
const faviconPath = this.extractFaviconPath(documentText);
const documentText = await this.fetchDocumentText(url);
const faviconPath = this.extractFaviconPath(documentText);
if (!faviconPath) {
throw new FaviconNotFoundException(`No favicon path found in ${url}`);
}
if (!faviconPath) {
throw new FaviconNotFoundException(`No favicon path found in ${url}`);
}
if (faviconPath.startsWith('http')) {
try {
return await this.fetchFavicon(faviconPath);
} catch {
logger.debug(`Unable to retrieve favicon from ${faviconPath}`);
}
}
if (faviconPath.startsWith('http')) {
try {
return await this.fetchFavicon(faviconPath);
} catch {
logger.debug(`Unable to retrieve favicon from ${faviconPath}`);
}
}
return this.fetchFaviconFromPath(url, faviconPath);
}
return this.fetchFaviconFromPath(url, faviconPath);
}
private async fetchFavicon(url: string): Promise<Favicon> {
const response = await this.fetchWithUserAgent(url);
if (!response.ok) {
throw new FaviconNotFoundException(`Request to favicon ${url} failed`);
}
private async fetchFavicon(url: string): Promise<Favicon> {
const response = await this.fetchWithUserAgent(url);
if (!response.ok) {
throw new FaviconNotFoundException(`Request to favicon ${url} failed`);
}
const blob = await response.blob();
if (!this.isImage(blob.type) || blob.size === 0) {
throw new FaviconNotFoundException(`Invalid image at ${url}`);
}
const blob = await response.blob();
if (!this.isImage(blob.type) || blob.size === 0) {
throw new FaviconNotFoundException(`Invalid image at ${url}`);
}
return {
buffer: Buffer.from(await blob.arrayBuffer()),
url: response.url,
type: blob.type,
size: blob.size,
};
}
return {
buffer: Buffer.from(await blob.arrayBuffer()),
url: response.url,
type: blob.type,
size: blob.size,
};
}
private async fetchDocumentText(url: string): Promise<string> {
const response = await this.fetchWithUserAgent(url);
if (!response.ok) {
throw new FaviconNotFoundException(`Request to ${url} failed`);
}
private async fetchDocumentText(url: string): Promise<string> {
const response = await this.fetchWithUserAgent(url);
if (!response.ok) {
throw new FaviconNotFoundException(`Request to ${url} failed`);
}
return await response.text();
}
return await response.text();
}
private extractFaviconPath(html: string): string | undefined {
const document = parse(html);
const link = document
.getElementsByTagName('link')
.find((element) => this.relList.includes(element.getAttribute('rel')!));
return link?.getAttribute('href');
}
private extractFaviconPath(html: string): string | undefined {
const document = parse(html);
const link = document
.getElementsByTagName('link')
.find((element) => this.relList.includes(element.getAttribute('rel')!));
return link?.getAttribute('href');
}
private async fetchFaviconFromPath(
baseUrl: string,
path: string
): Promise<Favicon> {
if (this.isBase64Image(path)) {
const buffer = this.convertBase64ToBuffer(path);
return {
buffer,
type: 'image/x-icon',
size: buffer.length,
url: path,
};
}
private async fetchFaviconFromPath(
baseUrl: string,
path: string
): Promise<Favicon> {
if (this.isBase64Image(path)) {
const buffer = this.convertBase64ToBuffer(path);
return {
buffer,
type: 'image/x-icon',
size: buffer.length,
url: path,
};
}
const faviconUrl = this.buildFaviconUrl(baseUrl, path);
return this.fetchFavicon(faviconUrl);
}
const faviconUrl = this.buildFaviconUrl(baseUrl, path);
return this.fetchFavicon(faviconUrl);
}
private buildFaviconUrl(base: string, path: string): string {
const { origin } = new URL(base);
if (path.startsWith('/')) {
return origin + path;
}
private buildFaviconUrl(base: string, path: string): string {
const { origin } = new URL(base);
if (path.startsWith('/')) {
return origin + path;
}
const basePath = this.urlWithoutSearchParams(base);
const baseUrl = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath;
return `${baseUrl}/${path}`;
}
const basePath = this.urlWithoutSearchParams(base);
const baseUrl = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath;
return `${baseUrl}/${path}`;
}
private urlWithoutSearchParams(url: string): string {
const { protocol, host, pathname } = new URL(url);
return `${protocol}//${host}${pathname}`;
}
private urlWithoutSearchParams(url: string): string {
const { protocol, host, pathname } = new URL(url);
return `${protocol}//${host}${pathname}`;
}
private isImage(type: string): boolean {
return type.startsWith('image/');
}
private isImage(type: string): boolean {
return type.startsWith('image/');
}
private isBase64Image(data: string): boolean {
return data.startsWith('data:image/');
}
private isBase64Image(data: string): boolean {
return data.startsWith('data:image/');
}
private convertBase64ToBuffer(base64: string): Buffer {
return Buffer.from(base64.split(',')[1], 'base64');
}
private convertBase64ToBuffer(base64: string): Buffer {
return Buffer.from(base64.split(',')[1], 'base64');
}
private async fetchWithUserAgent(url: string): Promise<Response> {
const headers = new Headers({ 'User-Agent': this.userAgent });
return fetch(url, { headers });
}
private async fetchWithUserAgent(url: string): Promise<Response> {
const headers = new Headers({ 'User-Agent': this.userAgent });
return fetch(url, { headers });
}
private sendImage(ctx: HttpContext, { buffer, type, size }: Favicon) {
ctx.response.header('Content-Type', type);
ctx.response.header('Content-Length', size.toString());
ctx.response.send(buffer, true);
}
private sendImage(ctx: HttpContext, { buffer, type, size }: Favicon) {
ctx.response.header('Content-Type', type);
ctx.response.header('Content-Length', size.toString());
ctx.response.send(buffer, true);
}
}

View File

@@ -1,10 +1,10 @@
import CollectionsController from '#controllers/collections_controller';
import Link from '#models/link';
import {
createLinkValidator,
deleteLinkValidator,
updateLinkFavoriteStatusValidator,
updateLinkValidator,
createLinkValidator,
deleteLinkValidator,
updateLinkFavoriteStatusValidator,
updateLinkValidator,
} from '#validators/link';
import { inject } from '@adonisjs/core';
import type { HttpContext } from '@adonisjs/core/http';
@@ -12,120 +12,120 @@ import db from '@adonisjs/lucid/services/db';
@inject()
export default class LinksController {
constructor(protected collectionsController: CollectionsController) {}
constructor(protected collectionsController: CollectionsController) {}
async showCreatePage({ auth, inertia }: HttpContext) {
const collections =
await this.collectionsController.getCollectionsByAuthorId(auth.user!.id);
return inertia.render('links/create', { collections });
}
async showCreatePage({ auth, inertia }: HttpContext) {
const collections =
await this.collectionsController.getCollectionsByAuthorId(auth.user!.id);
return inertia.render('links/create', { collections });
}
async store({ auth, request, response }: HttpContext) {
const { collectionId, ...payload } =
await request.validateUsing(createLinkValidator);
async store({ auth, request, response }: HttpContext) {
const { collectionId, ...payload } =
await request.validateUsing(createLinkValidator);
await this.collectionsController.getCollectionById(
collectionId,
auth.user!.id
);
await Link.create({
...payload,
collectionId,
authorId: auth.user?.id!,
});
return this.collectionsController.redirectToCollectionId(
response,
collectionId
);
}
await this.collectionsController.getCollectionById(
collectionId,
auth.user!.id
);
await Link.create({
...payload,
collectionId,
authorId: auth.user?.id!,
});
return this.collectionsController.redirectToCollectionId(
response,
collectionId
);
}
async showEditPage({ auth, inertia, request, response }: HttpContext) {
const linkId = request.qs()?.linkId;
if (!linkId) {
return response.redirectToNamedRoute('dashboard');
}
async showEditPage({ auth, inertia, request, response }: HttpContext) {
const linkId = request.qs()?.linkId;
if (!linkId) {
return response.redirectToNamedRoute('dashboard');
}
const userId = auth.user!.id;
const collections =
await this.collectionsController.getCollectionsByAuthorId(userId);
const link = await this.getLinkById(linkId, userId);
const userId = auth.user!.id;
const collections =
await this.collectionsController.getCollectionsByAuthorId(userId);
const link = await this.getLinkById(linkId, userId);
return inertia.render('links/edit', { collections, link });
}
return inertia.render('links/edit', { collections, link });
}
async update({ request, auth, response }: HttpContext) {
const { params, ...payload } =
await request.validateUsing(updateLinkValidator);
async update({ request, auth, response }: HttpContext) {
const { params, ...payload } =
await request.validateUsing(updateLinkValidator);
// Throw if invalid link id provided
await this.getLinkById(params.id, auth.user!.id);
// Throw if invalid link id provided
await this.getLinkById(params.id, auth.user!.id);
await Link.updateOrCreate(
{
id: params.id,
},
payload
);
await Link.updateOrCreate(
{
id: params.id,
},
payload
);
return response.redirectToNamedRoute('dashboard', {
qs: { collectionId: payload.collectionId },
});
}
return response.redirectToNamedRoute('dashboard', {
qs: { collectionId: payload.collectionId },
});
}
async toggleFavorite({ request, auth, response }: HttpContext) {
const { params, favorite } = await request.validateUsing(
updateLinkFavoriteStatusValidator
);
async toggleFavorite({ request, auth, response }: HttpContext) {
const { params, favorite } = await request.validateUsing(
updateLinkFavoriteStatusValidator
);
// Throw if invalid link id provided
await this.getLinkById(params.id, auth.user!.id);
// Throw if invalid link id provided
await this.getLinkById(params.id, auth.user!.id);
await Link.updateOrCreate(
{
id: params.id,
},
{ favorite }
);
await Link.updateOrCreate(
{
id: params.id,
},
{ favorite }
);
return response.json({ status: 'ok' });
}
return response.json({ status: 'ok' });
}
async showDeletePage({ auth, inertia, request, response }: HttpContext) {
const linkId = request.qs()?.linkId;
if (!linkId) {
return response.redirectToNamedRoute('dashboard');
}
async showDeletePage({ auth, inertia, request, response }: HttpContext) {
const linkId = request.qs()?.linkId;
if (!linkId) {
return response.redirectToNamedRoute('dashboard');
}
const link = await this.getLinkById(linkId, auth.user!.id);
await link.load('collection');
return inertia.render('links/delete', { link });
}
const link = await this.getLinkById(linkId, auth.user!.id);
await link.load('collection');
return inertia.render('links/delete', { link });
}
async delete({ request, auth, response }: HttpContext) {
const { params } = await request.validateUsing(deleteLinkValidator);
async delete({ request, auth, response }: HttpContext) {
const { params } = await request.validateUsing(deleteLinkValidator);
const link = await this.getLinkById(params.id, auth.user!.id);
await link.delete();
const link = await this.getLinkById(params.id, auth.user!.id);
await link.delete();
return response.redirectToNamedRoute('dashboard', {
qs: { collectionId: link.id },
});
}
return response.redirectToNamedRoute('dashboard', {
qs: { collectionId: link.id },
});
}
async getTotalLinksCount() {
const totalCount = await db.from('links').count('* as total');
return Number(totalCount[0].total);
}
async getTotalLinksCount() {
const totalCount = await db.from('links').count('* as total');
return Number(totalCount[0].total);
}
/**
* Get link by id.
*
* /!\ Only return private link (create by the current user)
*/
private async getLinkById(id: Link['id'], userId: Link['id']) {
return await Link.query()
.where('id', id)
.andWhere('author_id', userId)
.firstOrFail();
}
/**
* Get link by id.
*
* /!\ Only return private link (create by the current user)
*/
private async getLinkById(id: Link['id'], userId: Link['id']) {
return await Link.query()
.where('id', id)
.andWhere('author_id', userId)
.firstOrFail();
}
}

View File

@@ -2,17 +2,17 @@ import type { HttpContext } from '@adonisjs/core/http';
import db from '@adonisjs/lucid/services/db';
export default class SearchesController {
async search({ request, auth }: HttpContext) {
const term = request.qs()?.term;
if (!term) {
console.warn('qs term null');
return { error: 'missing "term" query param' };
}
async search({ request, auth }: HttpContext) {
const term = request.qs()?.term;
if (!term) {
console.warn('qs term null');
return { error: 'missing "term" query param' };
}
const { rows } = await db.rawQuery('SELECT * FROM search_text(?, ?)', [
term,
auth.user!.id,
]);
return { results: rows };
}
const { rows } = await db.rawQuery('SELECT * FROM search_text(?, ?)', [
term,
auth.user!.id,
]);
return { results: rows };
}
}

View File

@@ -4,21 +4,21 @@ import { getSharedCollectionValidator } from '#validators/shared_collection';
import type { HttpContext } from '@adonisjs/core/http';
export default class SharedCollectionsController {
async index({ request, inertia }: HttpContext) {
const { params } = await request.validateUsing(
getSharedCollectionValidator
);
async index({ request, inertia }: HttpContext) {
const { params } = await request.validateUsing(
getSharedCollectionValidator
);
const collection = await this.getSharedCollectionById(params.id);
return inertia.render('shared', { collection });
}
const collection = await this.getSharedCollectionById(params.id);
return inertia.render('shared', { collection });
}
private async getSharedCollectionById(id: Collection['id']) {
return await Collection.query()
.where('id', id)
.andWhere('visibility', Visibility.PUBLIC)
.preload('links')
.preload('author')
.firstOrFail();
}
private async getSharedCollectionById(id: Collection['id']) {
return await Collection.query()
.where('id', id)
.andWhere('visibility', Visibility.PUBLIC)
.preload('links')
.preload('author')
.firstOrFail();
}
}

View File

@@ -5,74 +5,74 @@ import db from '@adonisjs/lucid/services/db';
import { RouteName } from '@izzyjs/route/types';
export default class UsersController {
private redirectTo: RouteName = 'auth.login';
private redirectTo: RouteName = 'auth.login';
login({ inertia }: HttpContext) {
return inertia.render('login');
}
login({ inertia }: HttpContext) {
return inertia.render('login');
}
google = ({ ally }: HttpContext) => ally.use('google').redirect();
google = ({ ally }: HttpContext) => ally.use('google').redirect();
async callbackAuth({ ally, auth, response, session }: HttpContext) {
const google = ally.use('google');
if (google.accessDenied()) {
// TODO: translate error messages + show them in UI
session.flash('flash', 'Access was denied');
return response.redirectToNamedRoute(this.redirectTo);
}
async callbackAuth({ ally, auth, response, session }: HttpContext) {
const google = ally.use('google');
if (google.accessDenied()) {
// TODO: translate error messages + show them in UI
session.flash('flash', 'Access was denied');
return response.redirectToNamedRoute(this.redirectTo);
}
if (google.stateMisMatch()) {
session.flash('flash', 'Request expired. Retry again');
return response.redirectToNamedRoute(this.redirectTo);
}
if (google.stateMisMatch()) {
session.flash('flash', 'Request expired. Retry again');
return response.redirectToNamedRoute(this.redirectTo);
}
if (google.hasError()) {
session.flash('flash', google.getError() || 'Something went wrong');
return response.redirectToNamedRoute(this.redirectTo);
}
if (google.hasError()) {
session.flash('flash', google.getError() || 'Something went wrong');
return response.redirectToNamedRoute(this.redirectTo);
}
const userCount = await db.from('users').count('* as total');
const {
email,
id: providerId,
name,
nickName,
avatarUrl,
token,
} = await google.user();
const user = await User.updateOrCreate(
{
email,
},
{
email,
providerId,
name,
nickName,
avatarUrl,
token,
providerType: 'google',
isAdmin: userCount[0].total === '0' ? true : undefined,
}
);
const userCount = await db.from('users').count('* as total');
const {
email,
id: providerId,
name,
nickName,
avatarUrl,
token,
} = await google.user();
const user = await User.updateOrCreate(
{
email,
},
{
email,
providerId,
name,
nickName,
avatarUrl,
token,
providerType: 'google',
isAdmin: userCount[0].total === '0' ? true : undefined,
}
);
await auth.use('web').login(user);
session.flash('flash', 'Successfully authenticated');
logger.info(`[${user.email}] auth success`);
await auth.use('web').login(user);
session.flash('flash', 'Successfully authenticated');
logger.info(`[${user.email}] auth success`);
response.redirectToNamedRoute('dashboard');
}
response.redirectToNamedRoute('dashboard');
}
async logout({ auth, response, session }: HttpContext) {
await auth.use('web').logout();
session.flash('flash', 'Successfully disconnected');
logger.info(`[${auth.user?.email}] disconnected successfully`);
response.redirectToNamedRoute(this.redirectTo);
}
async logout({ auth, response, session }: HttpContext) {
await auth.use('web').logout();
session.flash('flash', 'Successfully disconnected');
logger.info(`[${auth.user?.email}] disconnected successfully`);
response.redirectToNamedRoute(this.redirectTo);
}
async getAllUsersWithTotalRelations() {
return User.query()
.withCount('collections', (q) => q.as('totalCollections'))
.withCount('links', (q) => q.as('totalLinks'));
}
async getAllUsersWithTotalRelations() {
return User.query()
.withCount('collections', (q) => q.as('totalCollections'))
.withCount('links', (q) => q.as('totalLinks'));
}
}

View File

@@ -1,4 +1,4 @@
export enum Visibility {
PUBLIC = 'PUBLIC',
PRIVATE = 'PRIVATE',
PUBLIC = 'PUBLIC',
PRIVATE = 'PRIVATE',
}

View File

@@ -5,16 +5,16 @@ import { createReadStream } from 'node:fs';
import { resolve } from 'node:path';
export default class FaviconNotFoundException extends Exception {
static status = 404;
static code = 'E_FAVICON_NOT_FOUND';
static status = 404;
static code = 'E_FAVICON_NOT_FOUND';
async handle(error: this, ctx: HttpContext) {
const readStream = createReadStream(
resolve(process.cwd(), './public/empty-image.png')
);
async handle(error: this, ctx: HttpContext) {
const readStream = createReadStream(
resolve(process.cwd(), './public/empty-image.png')
);
ctx.response.header('Content-Type', 'image/png');
ctx.response.stream(readStream);
logger.debug(error.message);
}
ctx.response.header('Content-Type', 'image/png');
ctx.response.stream(readStream);
logger.debug(error.message);
}
}

View File

@@ -1,54 +1,54 @@
import { ExceptionHandler, HttpContext } from '@adonisjs/core/http';
import app from '@adonisjs/core/services/app';
import type {
StatusPageRange,
StatusPageRenderer,
StatusPageRange,
StatusPageRenderer,
} from '@adonisjs/core/types/http';
import { errors } from '@adonisjs/lucid';
export default class HttpExceptionHandler extends ExceptionHandler {
/**
* In debug mode, the exception handler will display verbose errors
* with pretty printed stack traces.
*/
protected debug = !app.inProduction;
/**
* In debug mode, the exception handler will display verbose errors
* with pretty printed stack traces.
*/
protected debug = !app.inProduction;
/**
* Status pages are used to display a custom HTML pages for certain error
* codes. You might want to enable them in production only, but feel
* free to enable them in development as well.
*/
protected renderStatusPages = app.inProduction;
/**
* Status pages are used to display a custom HTML pages for certain error
* codes. You might want to enable them in production only, but feel
* free to enable them in development as well.
*/
protected renderStatusPages = app.inProduction;
/**
* Status pages is a collection of error code range and a callback
* to return the HTML contents to send as a response.
*/
protected statusPages: Record<StatusPageRange, StatusPageRenderer> = {
'404': (error, { inertia }) =>
inertia.render('errors/not_found', { error }),
'500..599': (error, { inertia }) =>
inertia.render('errors/server_error', { error }),
};
/**
* Status pages is a collection of error code range and a callback
* to return the HTML contents to send as a response.
*/
protected statusPages: Record<StatusPageRange, StatusPageRenderer> = {
'404': (error, { inertia }) =>
inertia.render('errors/not_found', { error }),
'500..599': (error, { inertia }) =>
inertia.render('errors/server_error', { error }),
};
/**
* The method is used for handling errors and returning
* response to the client
*/
async handle(error: unknown, ctx: HttpContext) {
if (error instanceof errors.E_ROW_NOT_FOUND) {
return ctx.response.redirectToNamedRoute('dashboard');
}
return super.handle(error, ctx);
}
/**
* The method is used for handling errors and returning
* response to the client
*/
async handle(error: unknown, ctx: HttpContext) {
if (error instanceof errors.E_ROW_NOT_FOUND) {
return ctx.response.redirectToNamedRoute('dashboard');
}
return super.handle(error, ctx);
}
/**
* The method is used to report error to the logging service or
* the a third party error monitoring service.
*
* @note You should not attempt to send a response from this method.
*/
async report(error: unknown, ctx: HttpContext) {
return super.report(error, ctx);
}
/**
* The method is used to report error to the logging service or
* the a third party error monitoring service.
*
* @note You should not attempt to send a response from this method.
*/
async report(error: unknown, ctx: HttpContext) {
return super.report(error, ctx);
}
}

View File

@@ -2,9 +2,9 @@ import { BentoCache, bentostore } from 'bentocache';
import { memoryDriver } from 'bentocache/drivers/memory';
export const cache = new BentoCache({
default: 'cache',
default: 'cache',
stores: {
cache: bentostore().useL1Layer(memoryDriver({ maxSize: 10_000 })),
},
stores: {
cache: bentostore().useL1Layer(memoryDriver({ maxSize: 10_000 })),
},
});

View File

@@ -2,10 +2,10 @@ import type { HttpContext } from '@adonisjs/core/http';
import type { NextFn } from '@adonisjs/core/types/http';
export default class AdminMiddleware {
async handle(ctx: HttpContext, next: NextFn) {
if (!ctx.auth.user?.isAdmin) {
return ctx.response.redirectToNamedRoute('dashboard');
}
return next();
}
async handle(ctx: HttpContext, next: NextFn) {
if (!ctx.auth.user?.isAdmin) {
return ctx.response.redirectToNamedRoute('dashboard');
}
return next();
}
}

View File

@@ -8,21 +8,21 @@ import { route } from '@izzyjs/route/client';
* access to unauthenticated users.
*/
export default class AuthMiddleware {
/**
* The URL to redirect to, when authentication fails
*/
redirectTo = route('auth.login').url;
/**
* The URL to redirect to, when authentication fails
*/
redirectTo = route('auth.login').url;
async handle(
ctx: HttpContext,
next: NextFn,
options: {
guards?: (keyof Authenticators)[];
} = {}
) {
await ctx.auth.authenticateUsing(options.guards, {
loginRoute: this.redirectTo,
});
return next();
}
async handle(
ctx: HttpContext,
next: NextFn,
options: {
guards?: (keyof Authenticators)[];
} = {}
) {
await ctx.auth.authenticateUsing(options.guards, {
loginRoute: this.redirectTo,
});
return next();
}
}

View File

@@ -10,10 +10,10 @@ import { NextFn } from '@adonisjs/core/types/http';
* - And bind "Logger" class to the "ctx.logger" object
*/
export default class ContainerBindingsMiddleware {
handle(ctx: HttpContext, next: NextFn) {
ctx.containerResolver.bindValue(HttpContext, ctx);
ctx.containerResolver.bindValue(Logger, ctx.logger);
handle(ctx: HttpContext, next: NextFn) {
ctx.containerResolver.bindValue(HttpContext, ctx);
ctx.containerResolver.bindValue(Logger, ctx.logger);
return next();
}
return next();
}
}

View File

@@ -10,22 +10,22 @@ import type { Authenticators } from '@adonisjs/auth/types';
* is already logged-in
*/
export default class GuestMiddleware {
/**
* The URL to redirect to when user is logged-in
*/
redirectTo = '/';
/**
* The URL to redirect to when user is logged-in
*/
redirectTo = '/';
async handle(
ctx: HttpContext,
next: NextFn,
options: { guards?: (keyof Authenticators)[] } = {}
) {
for (let guard of options.guards || [ctx.auth.defaultGuard]) {
if (await ctx.auth.use(guard).check()) {
return ctx.response.redirect(this.redirectTo, true);
}
}
async handle(
ctx: HttpContext,
next: NextFn,
options: { guards?: (keyof Authenticators)[] } = {}
) {
for (let guard of options.guards || [ctx.auth.defaultGuard]) {
if (await ctx.auth.use(guard).check()) {
return ctx.response.redirect(this.redirectTo, true);
}
}
return next();
}
return next();
}
}

View File

@@ -2,16 +2,16 @@ import { HttpContext } from '@adonisjs/core/http';
import logger from '@adonisjs/core/services/logger';
export default class LogRequest {
async handle({ request }: HttpContext, next: () => Promise<void>) {
if (
!request.url().startsWith('/node_modules') &&
!request.url().startsWith('/inertia') &&
!request.url().startsWith('/@vite') &&
!request.url().startsWith('/@react-refresh') &&
!request.url().includes('.ts')
) {
logger.debug(`[${request.method()}]: ${request.url()}`);
}
await next();
}
async handle({ request }: HttpContext, next: () => Promise<void>) {
if (
!request.url().startsWith('/node_modules') &&
!request.url().startsWith('/inertia') &&
!request.url().startsWith('/@vite') &&
!request.url().startsWith('/@react-refresh') &&
!request.url().includes('.ts')
) {
logger.debug(`[${request.method()}]: ${request.url()}`);
}
await next();
}
}

View File

@@ -1,25 +1,25 @@
import {
BaseModel,
CamelCaseNamingStrategy,
column,
BaseModel,
CamelCaseNamingStrategy,
column,
} from '@adonisjs/lucid/orm';
import { DateTime } from 'luxon';
export default class AppBaseModel extends BaseModel {
static namingStrategy = new CamelCaseNamingStrategy();
serializeExtras = true;
static namingStrategy = new CamelCaseNamingStrategy();
serializeExtras = true;
@column({ isPrimary: true })
declare id: number;
@column({ isPrimary: true })
declare id: number;
@column.dateTime({
autoCreate: true,
})
declare createdAt: DateTime;
@column.dateTime({
autoCreate: true,
})
declare createdAt: DateTime;
@column.dateTime({
autoCreate: true,
autoUpdate: true,
})
declare updatedAt: DateTime;
@column.dateTime({
autoCreate: true,
autoUpdate: true,
})
declare updatedAt: DateTime;
}

View File

@@ -6,24 +6,24 @@ import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations';
import { Visibility } from '#enums/visibility';
export default class Collection extends AppBaseModel {
@column()
declare name: string;
@column()
declare name: string;
@column()
declare description: string | null;
@column()
declare description: string | null;
@column()
declare visibility: Visibility;
@column()
declare visibility: Visibility;
@column()
declare nextId: number;
@column()
declare nextId: number;
@column()
declare authorId: number;
@column()
declare authorId: number;
@belongsTo(() => User, { foreignKey: 'authorId' })
declare author: BelongsTo<typeof User>;
@belongsTo(() => User, { foreignKey: 'authorId' })
declare author: BelongsTo<typeof User>;
@hasMany(() => Link)
declare links: HasMany<typeof Link>;
@hasMany(() => Link)
declare links: HasMany<typeof Link>;
}

View File

@@ -5,27 +5,27 @@ import { belongsTo, column } from '@adonisjs/lucid/orm';
import type { BelongsTo } from '@adonisjs/lucid/types/relations';
export default class Link extends AppBaseModel {
@column()
declare name: string;
@column()
declare name: string;
@column()
declare description: string | null;
@column()
declare description: string | null;
@column()
declare url: string;
@column()
declare url: string;
@column()
declare favorite: boolean;
@column()
declare favorite: boolean;
@column()
declare collectionId: number;
@column()
declare collectionId: number;
@belongsTo(() => Collection, { foreignKey: 'collectionId' })
declare collection: BelongsTo<typeof Collection>;
@belongsTo(() => Collection, { foreignKey: 'collectionId' })
declare collection: BelongsTo<typeof Collection>;
@column()
declare authorId: number;
@column()
declare authorId: number;
@belongsTo(() => User, { foreignKey: 'authorId' })
declare author: BelongsTo<typeof User>;
@belongsTo(() => User, { foreignKey: 'authorId' })
declare author: BelongsTo<typeof User>;
}

View File

@@ -6,42 +6,42 @@ import type { HasMany } from '@adonisjs/lucid/types/relations';
import AppBaseModel from './app_base_model.js';
export default class User extends AppBaseModel {
@column()
declare email: string;
@column()
declare email: string;
@column()
declare name: string;
@column()
declare name: string;
@column()
declare nickName: string; // public username
@column()
declare nickName: string; // public username
@column()
declare avatarUrl: string;
@column()
declare avatarUrl: string;
@column()
declare isAdmin: boolean;
@column()
declare isAdmin: boolean;
@column({ serializeAs: null })
declare token?: GoogleToken;
@column({ serializeAs: null })
declare token?: GoogleToken;
@column({ serializeAs: null })
declare providerId: number;
@column({ serializeAs: null })
declare providerId: number;
@column({ serializeAs: null })
declare providerType: 'google';
@column({ serializeAs: null })
declare providerType: 'google';
@hasMany(() => Collection, {
foreignKey: 'authorId',
})
declare collections: HasMany<typeof Collection>;
@hasMany(() => Collection, {
foreignKey: 'authorId',
})
declare collections: HasMany<typeof Collection>;
@hasMany(() => Link, {
foreignKey: 'authorId',
})
declare links: HasMany<typeof Link>;
@hasMany(() => Link, {
foreignKey: 'authorId',
})
declare links: HasMany<typeof Link>;
@computed()
get fullname() {
return this.nickName || this.name;
}
@computed()
get fullname() {
return this.nickName || this.name;
}
}

View File

@@ -2,36 +2,36 @@ import { Visibility } from '#enums/visibility';
import vine, { SimpleMessagesProvider } from '@vinejs/vine';
const params = vine.object({
id: vine.number(),
id: vine.number(),
});
export const createCollectionValidator = vine.compile(
vine.object({
name: vine.string().trim().minLength(1).maxLength(254),
description: vine.string().trim().maxLength(254).nullable(),
visibility: vine.enum(Visibility),
nextId: vine.number().optional(),
})
vine.object({
name: vine.string().trim().minLength(1).maxLength(254),
description: vine.string().trim().maxLength(254).nullable(),
visibility: vine.enum(Visibility),
nextId: vine.number().optional(),
})
);
export const updateCollectionValidator = vine.compile(
vine.object({
name: vine.string().trim().minLength(1).maxLength(254),
description: vine.string().trim().maxLength(254).nullable(),
visibility: vine.enum(Visibility),
nextId: vine.number().optional(),
vine.object({
name: vine.string().trim().minLength(1).maxLength(254),
description: vine.string().trim().maxLength(254).nullable(),
visibility: vine.enum(Visibility),
nextId: vine.number().optional(),
params,
})
params,
})
);
export const deleteCollectionValidator = vine.compile(
vine.object({
params,
})
vine.object({
params,
})
);
createCollectionValidator.messagesProvider = new SimpleMessagesProvider({
name: 'Collection name is required',
'visibility.required': 'Collection visibiliy is required',
name: 'Collection name is required',
'visibility.required': 'Collection visibiliy is required',
});

View File

@@ -1,43 +1,43 @@
import vine from '@vinejs/vine';
const params = vine.object({
id: vine.number(),
id: vine.number(),
});
export const createLinkValidator = vine.compile(
vine.object({
name: vine.string().trim().minLength(1).maxLength(254),
description: vine.string().trim().maxLength(300).optional(),
url: vine.string().trim(),
favorite: vine.boolean(),
collectionId: vine.number(),
})
vine.object({
name: vine.string().trim().minLength(1).maxLength(254),
description: vine.string().trim().maxLength(300).optional(),
url: vine.string().trim(),
favorite: vine.boolean(),
collectionId: vine.number(),
})
);
export const updateLinkValidator = vine.compile(
vine.object({
name: vine.string().trim().minLength(1).maxLength(254),
description: vine.string().trim().maxLength(300).optional(),
url: vine.string().trim(),
favorite: vine.boolean(),
collectionId: vine.number(),
vine.object({
name: vine.string().trim().minLength(1).maxLength(254),
description: vine.string().trim().maxLength(300).optional(),
url: vine.string().trim(),
favorite: vine.boolean(),
collectionId: vine.number(),
params,
})
params,
})
);
export const deleteLinkValidator = vine.compile(
vine.object({
params,
})
vine.object({
params,
})
);
export const updateLinkFavoriteStatusValidator = vine.compile(
vine.object({
favorite: vine.boolean(),
vine.object({
favorite: vine.boolean(),
params: vine.object({
id: vine.number(),
}),
})
params: vine.object({
id: vine.number(),
}),
})
);

View File

@@ -1,11 +1,11 @@
import vine from '@vinejs/vine';
const params = vine.object({
id: vine.number(),
id: vine.number(),
});
export const getSharedCollectionValidator = vine.compile(
vine.object({
params,
})
vine.object({
params,
})
);

View File

@@ -1,7 +1,7 @@
import vine from '@vinejs/vine';
export const updateUserThemeValidator = vine.compile(
vine.object({
preferDarkTheme: vine.boolean(),
})
vine.object({
preferDarkTheme: vine.boolean(),
})
);