switch to rails
This commit is contained in:
		
							parent
							
								
									83bc981152
								
							
						
					
					
						commit
						68c64808c5
					
				
					 71 changed files with 0 additions and 21335 deletions
				
			
		|  | @ -1,62 +0,0 @@ | |||
| import { PrismaClient } from "@prisma/client"; | ||||
| import invariant from "tiny-invariant"; | ||||
| 
 | ||||
| let prisma: PrismaClient; | ||||
| 
 | ||||
| declare global { | ||||
|   var __db__: PrismaClient; | ||||
| } | ||||
| 
 | ||||
| // this is needed because in development we don't want to restart
 | ||||
| // the server with every change, but we want to make sure we don't
 | ||||
| // create a new connection to the DB with every change either.
 | ||||
| // in production we'll have a single connection to the DB.
 | ||||
| if (process.env.NODE_ENV === "production") { | ||||
|   prisma = getClient(); | ||||
| } else { | ||||
|   if (!global.__db__) { | ||||
|     global.__db__ = getClient(); | ||||
|   } | ||||
|   prisma = global.__db__; | ||||
| } | ||||
| 
 | ||||
| function getClient() { | ||||
|   const { DATABASE_URL } = process.env; | ||||
|   invariant(typeof DATABASE_URL === "string", "DATABASE_URL env var not set"); | ||||
| 
 | ||||
|   const databaseUrl = new URL(DATABASE_URL); | ||||
| 
 | ||||
|   const isLocalHost = databaseUrl.hostname === "localhost"; | ||||
| 
 | ||||
|   const PRIMARY_REGION = isLocalHost ? null : process.env.PRIMARY_REGION; | ||||
|   const FLY_REGION = isLocalHost ? null : process.env.FLY_REGION; | ||||
| 
 | ||||
|   const isReadReplicaRegion = !PRIMARY_REGION || PRIMARY_REGION === FLY_REGION; | ||||
| 
 | ||||
|   if (!isLocalHost) { | ||||
|     databaseUrl.host = `${FLY_REGION}.${databaseUrl.host}`; | ||||
|     if (!isReadReplicaRegion) { | ||||
|       // 5433 is the read-replica port
 | ||||
|       databaseUrl.port = "5433"; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   console.log(`🔌 setting up prisma client to ${databaseUrl.host}`); | ||||
|   // NOTE: during development if you change anything in this function, remember
 | ||||
|   // that this only runs once per server restart and won't automatically be
 | ||||
|   // re-run per request like everything else is. So if you need to change
 | ||||
|   // something in this file, you'll need to manually restart the server.
 | ||||
|   const client = new PrismaClient({ | ||||
|     datasources: { | ||||
|       db: { | ||||
|         url: databaseUrl.toString(), | ||||
|       }, | ||||
|     }, | ||||
|   }); | ||||
|   // connect eagerly
 | ||||
|   client.$connect(); | ||||
| 
 | ||||
|   return client; | ||||
| } | ||||
| 
 | ||||
| export { prisma }; | ||||
|  | @ -1,124 +0,0 @@ | |||
| import axios from 'axios'; | ||||
| import { prisma } from "~/db.server"; | ||||
| import { createUserSession } from '~/session.server'; | ||||
| 
 | ||||
| export interface DiscordUser { | ||||
|     id: string; | ||||
|     username: string; | ||||
|     discriminator: string; | ||||
|     avatar: string; | ||||
|     bot?: boolean; | ||||
|     system?: boolean; | ||||
|     mfa_enabled?: boolean; | ||||
| } | ||||
| 
 | ||||
| export interface AccessTokenResponse { | ||||
|     access_token: string; | ||||
|     expires: number; | ||||
|     refresh_token: string; | ||||
| } | ||||
| 
 | ||||
| export function getAccessToken(code: string): Promise<AccessTokenResponse> { | ||||
|     return new Promise( | ||||
|         (resolve, reject) => { | ||||
|             const body = new FormData(); | ||||
|             body.append("grant_type", "authorization_code"); | ||||
|             body.append("code", code); | ||||
|             body.append("redirect_url", process.env.DISCORD_REDIRECT_URI || ""); | ||||
| 
 | ||||
|             axios.post("https://discord.com/api/oauth2/token", body) | ||||
|                 .then((res) => { | ||||
|                     const { access_token, refresh_token, expires_in } = res.data; | ||||
| 
 | ||||
|                     resolve({ | ||||
|                         access_token, | ||||
|                         refresh_token, | ||||
|                         expires: Date.now() + expires_in * 1000 | ||||
|                     }); | ||||
|                 }) | ||||
|                 .catch(reject); | ||||
|         } | ||||
|     ) | ||||
| } | ||||
| 
 | ||||
| export async function authorize(access_code: string): Promise<AccessTokenResponse | undefined> { | ||||
|     return new Promise( | ||||
|         (resolve, reject) => { | ||||
|             const formData = new FormData(); | ||||
| 
 | ||||
|             formData.append("client_id", process.env.DISCORD_CLIENT_ID || ""); | ||||
|             formData.append("client_secret", process.env.DISCORD_CLIENT_SECRET || ""); | ||||
|             formData.append("grant_type", "authorization_code"); | ||||
|             formData.append("redirect_uri", process.env.DISCORD_REDIRECT_URI || ""); | ||||
|             formData.append("scope", "identify"); | ||||
|             formData.append("code", access_code); | ||||
| 
 | ||||
|             axios.post("https://discord.com/api/oauth2/token", formData) | ||||
|                 .then((res) => { | ||||
|                     const { access_token, refresh_token, expires_in } = res.data; | ||||
| 
 | ||||
|                     resolve({ | ||||
|                         access_token, | ||||
|                         refresh_token, | ||||
|                         expires: Date.now() + expires_in * 1000 | ||||
|                     }); | ||||
|                 }) | ||||
|                 .catch(reject); | ||||
|         } | ||||
|     ) | ||||
| } | ||||
| 
 | ||||
| export function getDiscordUser(token: string): Promise<DiscordUser> { | ||||
|     return new Promise( | ||||
|         (resolve, reject) => { | ||||
|             axios.get("https://discord.com/api/users/@me", { | ||||
|                 headers: { | ||||
|                     Authorization: `Bearer ${token}` | ||||
|                 } | ||||
|             }) | ||||
|                 .then( | ||||
|                     (res) => { | ||||
|                         resolve(res.data); | ||||
|                     } | ||||
|                 ) | ||||
|                 .catch(reject); | ||||
|         } | ||||
|     ) | ||||
| } | ||||
| 
 | ||||
| export async function discordLogin(request: Request, code: string): Promise<Response> { | ||||
|     return new Promise( | ||||
|         (resolve, reject) => { | ||||
|             getAccessToken(code) | ||||
|                 .then((token) => { | ||||
|                     getDiscordUser(token.access_token) | ||||
|                         .then(async (user) => { | ||||
|                             prisma.discordUser.upsert({ | ||||
|                                 create: { | ||||
|                                     discord_id: user.id, | ||||
|                                     access_token: token.access_token, | ||||
|                                     refresh_token: token.refresh_token, | ||||
|                                     expires: new Date(token.expires), | ||||
|                                     username: user.username, | ||||
|                                     discriminator: user.discriminator, | ||||
|                                 }, | ||||
|                                 update: { | ||||
|                                     discord_id: user.id, | ||||
|                                     access_token: token.access_token, | ||||
|                                     refresh_token: token.refresh_token, | ||||
|                                     expires: new Date(token.expires), | ||||
|                                     username: user.username, | ||||
|                                     discriminator: user.discriminator, | ||||
|                                 }, | ||||
|                                 where: { | ||||
|                                     discord_id: user.id | ||||
|                                 } | ||||
|                             }) | ||||
|                             .then(async () => { | ||||
|                                 resolve(await createUserSession({request, discord_id: user.id, redirectTo: '/'})); | ||||
|                             }) | ||||
|                         }) | ||||
|                 }) | ||||
|         } | ||||
|     ) | ||||
| } | ||||
|  | @ -1,4 +0,0 @@ | |||
| import { hydrate } from "react-dom"; | ||||
| import { RemixBrowser } from "remix"; | ||||
| 
 | ||||
| hydrate(<RemixBrowser />, document); | ||||
|  | @ -1,21 +0,0 @@ | |||
| import { renderToString } from "react-dom/server"; | ||||
| import { RemixServer } from "remix"; | ||||
| import type { EntryContext } from "remix"; | ||||
| 
 | ||||
| export default function handleRequest( | ||||
|   request: Request, | ||||
|   responseStatusCode: number, | ||||
|   responseHeaders: Headers, | ||||
|   remixContext: EntryContext | ||||
| ) { | ||||
|   const markup = renderToString( | ||||
|     <RemixServer context={remixContext} url={request.url} /> | ||||
|   ); | ||||
| 
 | ||||
|   responseHeaders.set("Content-Type", "text/html"); | ||||
| 
 | ||||
|   return new Response("<!DOCTYPE html>" + markup, { | ||||
|     status: responseStatusCode, | ||||
|     headers: responseHeaders, | ||||
|   }); | ||||
| } | ||||
|  | @ -1,65 +0,0 @@ | |||
| 
 | ||||
| import { prisma } from "~/db.server"; | ||||
| import type {User} from "@prisma/client"; | ||||
| import { AccessTokenResponse, DiscordUser } from "~/discord/index"; | ||||
| export type {User, Border} from "@prisma/client"; | ||||
| 
 | ||||
| export async function getUserByDiscordId(discord_id: User["discord_id"]) { | ||||
|   return prisma.user.findUnique({ where: { discord_id: discord_id || undefined }}); | ||||
| } | ||||
| 
 | ||||
| export async function getUserById(id: User["id"]) { | ||||
|   return prisma.user.findUnique({ where: { id } }); | ||||
| } | ||||
| 
 | ||||
| /// SHOULD ONLY BE USED WITH A CORRESPONDING OAUTH TOKEN
 | ||||
| export async function createUser(discord_id: User["discord_id"]) { | ||||
| 
 | ||||
|   return prisma.user.create({ | ||||
|     data: { | ||||
|       discord_id | ||||
|     }, | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| export async function createDiscordLogin(discord_id: DiscordUser["id"], token_response: AccessTokenResponse) { | ||||
|   return prisma.discordUser.create({ | ||||
|     data: { | ||||
|       discord_id, | ||||
|       access_token: token_response.access_token, | ||||
|       refresh_token: token_response.refresh_token, | ||||
|       expires: new Date(token_response.expires) | ||||
|     } | ||||
|   }) | ||||
| } | ||||
| 
 | ||||
| export type SessionInformation = { | ||||
|   bearer_token: string, | ||||
|   refresh_token: string | ||||
| }; | ||||
| 
 | ||||
| export async function discordIdentify(bearer_token: string, refresh_token: string): Promise<DiscordUser | SessionInformation | undefined> { | ||||
|   let user_info = await ( | ||||
|     await fetch("https://discord.com/api/users/@me", { | ||||
|      headers: { | ||||
|        Authorization: `Bearer ${bearer_token}` | ||||
|       } | ||||
|     }) | ||||
|   ).json(); | ||||
| 
 | ||||
|   if (!user_info["id"]) { | ||||
|     const form_data = new FormData(); | ||||
|     form_data.append("client_id", process.env.DISCORD_CLIENT_ID || ""); | ||||
|     form_data.append("client_secret", process.env.DISCORD_CLIENT_SECRET || ""); | ||||
|     form_data.append("grant_type", "refresh_token"); | ||||
|     form_data.append("refresh_token", refresh_token); | ||||
|     let refresh_info = await (await fetch("https://discord.com/api/oauth2/token", { | ||||
|       method: "POST", | ||||
|       body: form_data | ||||
|     })).json(); | ||||
| 
 | ||||
|     return refresh_info; | ||||
|   } | ||||
| 
 | ||||
|   return user_info; | ||||
| } | ||||
							
								
								
									
										40
									
								
								app/root.tsx
									
										
									
									
									
								
							
							
						
						
									
										40
									
								
								app/root.tsx
									
										
									
									
									
								
							|  | @ -1,40 +0,0 @@ | |||
| import { | ||||
|   json, | ||||
|   Links, | ||||
|   LiveReload, | ||||
|   Meta, | ||||
|   Outlet, | ||||
|   Scripts, | ||||
|   ScrollRestoration, | ||||
|   useLoaderData, | ||||
| } from "remix"; | ||||
| import type { LinksFunction, MetaFunction, LoaderFunction } from "remix"; | ||||
| 
 | ||||
| import tailwindStylesheetUrl from "./styles/tailwind.css"; | ||||
| 
 | ||||
| export const links: LinksFunction = () => { | ||||
|   return [{ rel: "stylesheet", href: tailwindStylesheetUrl }]; | ||||
| }; | ||||
| 
 | ||||
| export const meta: MetaFunction = () => ({ | ||||
|   charset: "utf-8", | ||||
|   title: "Border Selector", | ||||
|   viewport: "width=device-width,initial-scale=1", | ||||
| }); | ||||
| 
 | ||||
| export default function App() { | ||||
|   return ( | ||||
|     <html lang="en" className="h-full"> | ||||
|       <head> | ||||
|         <Meta /> | ||||
|         <Links /> | ||||
|       </head> | ||||
|       <body className="h-full"> | ||||
|         <Outlet /> | ||||
|         <ScrollRestoration /> | ||||
|         <Scripts /> | ||||
|         <LiveReload /> | ||||
|       </body> | ||||
|     </html> | ||||
|   ); | ||||
| } | ||||
|  | @ -1,23 +0,0 @@ | |||
| // learn more: https://fly.io/docs/reference/configuration/#services-http_checks
 | ||||
| import type { LoaderFunction } from "remix"; | ||||
| import { prisma } from "~/db.server"; | ||||
| 
 | ||||
| export const loader: LoaderFunction = async ({ request }) => { | ||||
|   const host = | ||||
|     request.headers.get("X-Forwarded-Host") ?? request.headers.get("host"); | ||||
| 
 | ||||
|   try { | ||||
|     // if we can connect to the database and make a simple query
 | ||||
|     // and make a HEAD request to ourselves, then we're good.
 | ||||
|     await Promise.all([ | ||||
|       prisma.user.count(), | ||||
|       fetch(`http://${host}`, { method: "HEAD" }).then((r) => { | ||||
|         if (!r.ok) return Promise.reject(r); | ||||
|       }), | ||||
|     ]); | ||||
|     return new Response("OK"); | ||||
|   } catch (error: unknown) { | ||||
|     console.log("healthcheck ❌", { error }); | ||||
|     return new Response("ERROR", { status: 500 }); | ||||
|   } | ||||
| }; | ||||
|  | @ -1,23 +0,0 @@ | |||
| import { json, Link, LoaderFunction, useLoaderData } from "remix"; | ||||
| import { getUser, getSession } from "~/session.server"; | ||||
| import { useOptionalUser } from "~/utils"; | ||||
| 
 | ||||
| export const loader: LoaderFunction = async ({ request }) => { | ||||
|   const user_info = await getUser(request); | ||||
|   return json(user_info); | ||||
| } | ||||
| 
 | ||||
| export default function Index() { | ||||
|   const discordUser = useLoaderData(); | ||||
|   console.log("discordUser is", discordUser); | ||||
|   return ( | ||||
|     <main className="relative min-h-screen bg-white sm:flex sm:items-center sm:justify-center"> | ||||
|       <h1>Do you love the color of the sky?</h1> | ||||
|       <br /> | ||||
|       <Link | ||||
|       to="/loginstart"> | ||||
|       Log in | ||||
|       </Link> | ||||
|     </main> | ||||
|   ); | ||||
| } | ||||
|  | @ -1,39 +0,0 @@ | |||
| import * as React from "react"; | ||||
| import type { ActionFunction, LoaderFunction, MetaFunction } from "remix"; | ||||
| import { | ||||
|   Form, | ||||
|   json, | ||||
|   Link, | ||||
|   useActionData, | ||||
|   redirect, | ||||
|   useSearchParams, | ||||
| } from "remix"; | ||||
| import { discordLogin } from "~/discord"; | ||||
| 
 | ||||
| import { createUserSession, getUserId } from "~/session.server"; | ||||
| 
 | ||||
| export const loader: LoaderFunction = async ({ request }) => { | ||||
|   const userId = await getUserId(request); | ||||
|   if (userId) return redirect("/"); | ||||
| 
 | ||||
|   const url = new URL(await request.url); | ||||
|   const accessCode = url.searchParams.get("code") || ""; | ||||
| 
 | ||||
|   const response = await discordLogin(request, accessCode); | ||||
|   console.log(response); | ||||
|   return "" | ||||
| }; | ||||
| 
 | ||||
| export const meta: MetaFunction = () => { | ||||
|   return { | ||||
|     title: "Login", | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export default function LoginPage() { | ||||
|   return ( | ||||
|     <div className="flex min-h-full flex-col justify-center"> | ||||
|       <p>Error logging in.</p>   | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
|  | @ -1,25 +0,0 @@ | |||
| import type { LoaderFunction, MetaFunction } from "remix"; | ||||
| import { | ||||
|   json, | ||||
|   redirect, | ||||
| } from "remix"; | ||||
| import { getUserId } from "~/session.server"; | ||||
| 
 | ||||
| export const loader: LoaderFunction = async ({ request }) => { | ||||
|   const userId = await getUserId(request); | ||||
|   if (userId) return redirect("/"); | ||||
|   const client_id = process.env.DISCORD_CLIENT_ID || ""; | ||||
|   const redirect_uri = process.env.DISCORD_REDIRECT_URI || ""; | ||||
|   return redirect(`https://discord.com/api/oauth2/authorize?client_id=${client_id}` + | ||||
|   `&response_type=code&redirect_uri=${encodeURIComponent(redirect_uri)}&scope=identify`); | ||||
| }; | ||||
| 
 | ||||
| export const meta: MetaFunction = () => { | ||||
|   return { | ||||
|     title: "Login", | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export default function LoginPage() { | ||||
|   return <div></div> | ||||
| } | ||||
|  | @ -1,11 +0,0 @@ | |||
| import type { ActionFunction, LoaderFunction } from "remix"; | ||||
| import { redirect } from "remix"; | ||||
| import { logout } from "~/session.server"; | ||||
| 
 | ||||
| export const action: ActionFunction = async ({ request }) => { | ||||
|   return logout(request); | ||||
| }; | ||||
| 
 | ||||
| export const loader: LoaderFunction = async () => { | ||||
|   return redirect("/"); | ||||
| }; | ||||
|  | @ -1,97 +0,0 @@ | |||
| import { createCookieSessionStorage, redirect } from "remix"; | ||||
| import invariant from "tiny-invariant"; | ||||
| 
 | ||||
| import { discordIdentify, User, SessionInformation } from "~/models/user.server"; | ||||
| import { getUserByDiscordId } from "~/models/user.server"; | ||||
| 
 | ||||
| invariant(process.env.SESSION_SECRET, "SESSION_SECRET must be set"); | ||||
| 
 | ||||
| export const sessionStorage = createCookieSessionStorage({ | ||||
|   cookie: { | ||||
|     name: "__session", | ||||
|     httpOnly: true, | ||||
|     maxAge: 0, | ||||
|     path: "/", | ||||
|     sameSite: "lax", | ||||
|     secrets: [process.env.SESSION_SECRET], | ||||
|     secure: process.env.NODE_ENV === "production", | ||||
|   }, | ||||
| }); | ||||
| 
 | ||||
| const USER_SESSION_KEY = "userId"; | ||||
| const BEARER_TOKEN_KEY = "bearerToken"; | ||||
| const REFRESH_TOKEN_KEY = "refreshToken"; | ||||
| 
 | ||||
| export async function getSession(request: Request) { | ||||
|   const cookie = request.headers.get("Cookie"); | ||||
|   return sessionStorage.getSession(cookie); | ||||
| } | ||||
| 
 | ||||
| export async function getUserId(request: Request): Promise<string | undefined> { | ||||
|   const session = await getSession(request); | ||||
|   const userId = session.get(USER_SESSION_KEY); | ||||
|   return userId; | ||||
| } | ||||
| 
 | ||||
| export async function getUser(request: Request): Promise<null | User> { | ||||
|   const userId = await getUserId(request); | ||||
|   if (userId === undefined) return null; | ||||
| 
 | ||||
|   const user = await getUserByDiscordId(userId); | ||||
|   if (user) return user; | ||||
| 
 | ||||
|   throw await logout(request); | ||||
| } | ||||
| 
 | ||||
| export async function requireUserId( | ||||
|   request: Request, | ||||
|   redirectTo: string = new URL(request.url).pathname | ||||
| ): Promise<string> { | ||||
|   const userId = await getUserId(request); | ||||
|   if (!userId) { | ||||
|     const searchParams = new URLSearchParams([["redirectTo", redirectTo]]); | ||||
|     throw redirect(`/loginstart?${searchParams}`); | ||||
|   } | ||||
|   return userId; | ||||
| } | ||||
| 
 | ||||
| export async function requireUser(request: Request) { | ||||
|   const session = await getSession(request); | ||||
|   const user_id = session.get(USER_SESSION_KEY); | ||||
|   const client_id = process.env.DISCORD_CLIENT_ID || ""; | ||||
|   const redirect_uri = process.env.DISCORD_REDIRECT_URI || ""; | ||||
|   if (!user_id) { | ||||
|     return redirect(`https://discord.com/api/oauth2/authorize?client_id=${client_id}` + | ||||
|     `&response_type=code&redirect_uri=${encodeURIComponent(redirect_uri)}&scope=identify`); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export async function createUserSession({ | ||||
|   request, | ||||
|   discord_id, | ||||
|   redirectTo, | ||||
| }: { | ||||
|   request: Request; | ||||
|   discord_id: string; | ||||
|   redirectTo: string; | ||||
| }) { | ||||
|   const session = await getSession(request); | ||||
|   session.set(USER_SESSION_KEY, discord_id); | ||||
|   console.log(discord_id); | ||||
|   return redirect(redirectTo, { | ||||
|     headers: { | ||||
|       "Set-Cookie": await sessionStorage.commitSession(session, { | ||||
|         maxAge: 60 * 60 * 24 * 7 // 7 days
 | ||||
|       }), | ||||
|     }, | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| export async function logout(request: Request) { | ||||
|   const session = await getSession(request); | ||||
|   return redirect("/", { | ||||
|     headers: { | ||||
|       "Set-Cookie": await sessionStorage.destroySession(session), | ||||
|     }, | ||||
|   }); | ||||
| } | ||||
							
								
								
									
										44
									
								
								app/utils.ts
									
										
									
									
									
								
							
							
						
						
									
										44
									
								
								app/utils.ts
									
										
									
									
									
								
							|  | @ -1,44 +0,0 @@ | |||
| import { useMemo } from "react"; | ||||
| import { useMatches } from "remix"; | ||||
| 
 | ||||
| import type { User } from "~/models/user.server"; | ||||
| 
 | ||||
| /** | ||||
|  * This base hook is used in other hooks to quickly search for specific data | ||||
|  * across all loader data using useMatches. | ||||
|  * @param {string} id The route id | ||||
|  * @returns {JSON|undefined} The router data or undefined if not found | ||||
|  */ | ||||
| export function useMatchesData( | ||||
|   id: string | ||||
| ): Record<string, unknown> | undefined { | ||||
|   const matchingRoutes = useMatches(); | ||||
|   const route = useMemo( | ||||
|     () => matchingRoutes.find((route) => route.id === id), | ||||
|     [matchingRoutes, id] | ||||
|   ); | ||||
|   return route?.data; | ||||
| } | ||||
| 
 | ||||
| function isUser(user: any): user is User { | ||||
|   return user && typeof user === "object" && typeof user.discord_id === "string"; | ||||
| } | ||||
| 
 | ||||
| export function useOptionalUser(): User | undefined { | ||||
|   const data = useMatchesData("root"); | ||||
|   console.log(data) | ||||
|   if (!data || !isUser(data.user)) { | ||||
|     return undefined; | ||||
|   } | ||||
|   return data.user; | ||||
| } | ||||
| 
 | ||||
| export function useUser(): User { | ||||
|   const maybeUser = useOptionalUser(); | ||||
|   if (!maybeUser) { | ||||
|     throw new Error( | ||||
|       "No user found in root loader, but user is required by useUser. If user is optional, try useOptionalUser instead." | ||||
|     ); | ||||
|   } | ||||
|   return maybeUser; | ||||
| } | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue