diff --git a/.dev.vars.template b/.dev.vars.template new file mode 100644 index 0000000..7216b6f --- /dev/null +++ b/.dev.vars.template @@ -0,0 +1,4 @@ +AUTH_SECRET = "" +GOOGLE_CALLBACK_BASE_URL = "http://localhost:5173" +GOOGLE_CLIENT_ID = "" +GOOGLE_CLIENT_SECRET = "" diff --git a/.gitignore b/.gitignore index 4643549..e93331c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,6 @@ node_modules /.cache /build .env -.dev.vars .wrangler +.dev.vars diff --git a/app/db/migrations/0000_fast_the_twelve.sql b/app/db/migrations/0000_fast_the_twelve.sql deleted file mode 100644 index 7370a77..0000000 --- a/app/db/migrations/0000_fast_the_twelve.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TABLE `resources` ( - `id` integer PRIMARY KEY NOT NULL, - `title` text NOT NULL, - `href` text NOT NULL -); diff --git a/app/db/migrations/meta/0000_snapshot.json b/app/db/migrations/meta/0000_snapshot.json deleted file mode 100644 index e9ff836..0000000 --- a/app/db/migrations/meta/0000_snapshot.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "c3c22972-b5ac-4b05-9ae7-8ada87f5e5a2", - "prevId": "00000000-0000-0000-0000-000000000000", - "tables": { - "resources": { - "name": "resources", - "columns": { - "id": { - "name": "id", - "type": "integer", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "href": { - "name": "href", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {} - } - }, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} \ No newline at end of file diff --git a/app/db/schema.server.ts b/app/db/schema.server.ts deleted file mode 100644 index cbfe04d..0000000 --- a/app/db/schema.server.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; - -export const resources = sqliteTable("resources", { - id: integer("id").primaryKey(), - title: text("title").notNull(), - href: text("href").notNull(), -}); diff --git a/app/libs/drizzle/clear.sql b/app/libs/drizzle/clear.sql new file mode 100644 index 0000000..21d746d --- /dev/null +++ b/app/libs/drizzle/clear.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS posts; +DROP TABLE IF EXISTS users; diff --git a/app/libs/drizzle/client.server.ts b/app/libs/drizzle/client.server.ts new file mode 100644 index 0000000..f7ed1b5 --- /dev/null +++ b/app/libs/drizzle/client.server.ts @@ -0,0 +1,6 @@ +import { drizzle } from "drizzle-orm/d1"; + +export const getDBClient = (d1: D1Database) => { + const db = drizzle(d1, { logger: import.meta.env.DEV }); + return db; +}; diff --git a/app/libs/drizzle/migrations/0000_sharp_piledriver.sql b/app/libs/drizzle/migrations/0000_sharp_piledriver.sql new file mode 100644 index 0000000..37e8355 --- /dev/null +++ b/app/libs/drizzle/migrations/0000_sharp_piledriver.sql @@ -0,0 +1,18 @@ +CREATE TABLE `posts` ( + `id` integer PRIMARY KEY NOT NULL, + `body` text, + `user_id` integer, + `created_at` text DEFAULT (current_timestamp) NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `users` ( + `id` integer PRIMARY KEY NOT NULL, + `provider` text NOT NULL, + `provider_id` text NOT NULL, + `name` text NOT NULL, + `icon` text, + `created_at` text DEFAULT (current_timestamp) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `users_provider_id_unique` ON `users` (`provider_id`); \ No newline at end of file diff --git a/app/libs/drizzle/migrations/meta/0000_snapshot.json b/app/libs/drizzle/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..98d9a70 --- /dev/null +++ b/app/libs/drizzle/migrations/meta/0000_snapshot.json @@ -0,0 +1,129 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "a0c45071-1d9d-4fff-b96d-52932ed1a195", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "posts": { + "name": "posts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "posts_user_id_users_id_fk": { + "name": "posts_user_id_users_id_fk", + "tableFrom": "posts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "users_provider_id_unique": { + "name": "users_provider_id_unique", + "columns": [ + "provider_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/app/db/migrations/meta/_journal.json b/app/libs/drizzle/migrations/meta/_journal.json similarity index 67% rename from app/db/migrations/meta/_journal.json rename to app/libs/drizzle/migrations/meta/_journal.json index a2b92af..1b5b00f 100644 --- a/app/db/migrations/meta/_journal.json +++ b/app/libs/drizzle/migrations/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "6", - "when": 1724486082546, - "tag": "0000_fast_the_twelve", + "when": 1724496526361, + "tag": "0000_sharp_piledriver", "breakpoints": true } ] diff --git a/app/libs/drizzle/schema.ts b/app/libs/drizzle/schema.ts new file mode 100644 index 0000000..82e4eda --- /dev/null +++ b/app/libs/drizzle/schema.ts @@ -0,0 +1,22 @@ +import { sql } from "drizzle-orm"; +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +export const users = sqliteTable("users", { + id: integer("id").primaryKey(), + provider: text("provider").notNull(), + providerId: text("provider_id").notNull().unique(), + name: text("name").notNull(), + icon: text("icon"), + createdAt: text("created_at") + .notNull() + .default(sql`(current_timestamp)`), +}); + +export const posts = sqliteTable("posts", { + id: integer("id").primaryKey(), + body: text("body"), + userId: integer("user_id").references(() => users.id), + createdAt: text("created_at") + .notNull() + .default(sql`(current_timestamp)`), +}); diff --git a/app/root.tsx b/app/root.tsx index 3d3d733..0c0d3eb 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -9,7 +9,7 @@ import "./tailwind.css"; export function Layout({ children }: { children: React.ReactNode }) { return ( - + diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx index 5c68e88..71f0f23 100644 --- a/app/routes/_index.tsx +++ b/app/routes/_index.tsx @@ -1,41 +1,154 @@ -import type { MetaFunction } from "@remix-run/cloudflare"; +import type { MetaFunction, LoaderFunctionArgs } from "@remix-run/cloudflare"; +import { json } from "@remix-run/cloudflare"; +import { Form, useLoaderData, useNavigation } from "@remix-run/react"; +import { desc, eq } from "drizzle-orm"; +import { useRef, useEffect } from "react"; +import { getDBClient } from "~/libs/drizzle/client.server"; +import { posts } from "~/libs/drizzle/schema"; +import { getAuthenticator } from "~/services/auth.server"; export const meta: MetaFunction = () => { return [ { title: "New Remix App" }, { name: "description", - content: "Welcome to Remix on Cloudflare!", + content: "Welcome to Remix! Using Vite and Cloudflare!", }, ]; }; +export const loader = async ({ context, request }: LoaderFunctionArgs) => { + const authenticator = getAuthenticator(context); + const user = await authenticator.isAuthenticated(request); + if (user) { + const db = getDBClient(context.cloudflare.env.DB); + const userPosts = await db + .select() + .from(posts) + .where(eq(posts.userId, user.id)) + .orderBy(desc(posts.id)); + return json({ user, posts: userPosts }); + } + return json({ user }); +}; + export default function Index() { - return ( -
-

Welcome to Remix on Cloudflare

-
+ + +
+ + + + + + + + + + + {data.posts.map((post) => ( + + + + + + + ))} + +
+ ID + + Body + + Created At + + Edit +
+ {post.id} + + {post.body} + + {post.createdAt} + +
+ + +
+
+ + + ); + } + return ( +
+

Home

+
+
+ +
+
); } diff --git a/app/routes/auth.google.callback.tsx b/app/routes/auth.google.callback.tsx new file mode 100644 index 0000000..f5a78d6 --- /dev/null +++ b/app/routes/auth.google.callback.tsx @@ -0,0 +1,10 @@ +import type { LoaderFunctionArgs } from "@remix-run/cloudflare"; +import { getAuthenticator } from "~/services/auth.server"; + +export const loader = ({ context, request }: LoaderFunctionArgs) => { + const authenticator = getAuthenticator(context); + return authenticator.authenticate("google", request, { + successRedirect: "/", + failureRedirect: "/", + }); +}; diff --git a/app/routes/auth.google.tsx b/app/routes/auth.google.tsx new file mode 100644 index 0000000..119f268 --- /dev/null +++ b/app/routes/auth.google.tsx @@ -0,0 +1,7 @@ +import type { ActionFunctionArgs } from "@remix-run/cloudflare"; +import { getAuthenticator } from "~/services/auth.server"; + +export const action = ({ context, request }: ActionFunctionArgs) => { + const authenticator = getAuthenticator(context); + return authenticator.authenticate("google", request); +}; diff --git a/app/routes/logout.tsx b/app/routes/logout.tsx new file mode 100644 index 0000000..067c4cb --- /dev/null +++ b/app/routes/logout.tsx @@ -0,0 +1,7 @@ +import type { ActionFunctionArgs } from "@remix-run/cloudflare"; +import { getAuthenticator } from "~/services/auth.server"; + +export const action = async ({ context, request }: ActionFunctionArgs) => { + const authenticator = getAuthenticator(context); + await authenticator.logout(request, { redirectTo: "/" }); +}; diff --git a/app/routes/posts.create.tsx b/app/routes/posts.create.tsx new file mode 100644 index 0000000..b90bf3d --- /dev/null +++ b/app/routes/posts.create.tsx @@ -0,0 +1,22 @@ +import { type ActionFunctionArgs, redirect } from "@remix-run/cloudflare"; +import { getDBClient } from "~/libs/drizzle/client.server"; +import { posts } from "~/libs/drizzle/schema"; +import { getAuthenticator } from "~/services/auth.server"; + +export const action = async ({ context, request }: ActionFunctionArgs) => { + const authenticator = getAuthenticator(context); + const user = await authenticator.isAuthenticated(request); + if (user) { + const db = getDBClient(context.cloudflare.env.DB); + const formData = await request.formData(); + const postBody = formData.get("post-body")?.toString(); + // validation + if (postBody === undefined || postBody.length === 0) { + return new Response("Post body is empty", { status: 500 }); + } + await db + .insert(posts) + .values({ body: postBody?.toString(), userId: user.id }); + } + return redirect("/"); +}; diff --git a/app/routes/posts.delete.tsx b/app/routes/posts.delete.tsx new file mode 100644 index 0000000..db1355b --- /dev/null +++ b/app/routes/posts.delete.tsx @@ -0,0 +1,25 @@ +import { type ActionFunctionArgs, redirect } from "@remix-run/cloudflare"; +import { and, eq } from "drizzle-orm"; +import { getDBClient } from "~/libs/drizzle/client.server"; +import { posts } from "~/libs/drizzle/schema"; +import { getAuthenticator } from "~/services/auth.server"; + +export const action = async ({ context, request }: ActionFunctionArgs) => { + const authenticator = getAuthenticator(context); + const user = await authenticator.isAuthenticated(request); + if (user) { + const db = getDBClient(context.cloudflare.env.DB); + const formData = await request.formData(); + const postId = formData.get("post-id")?.toString(); + // validation + if (postId === undefined || Number.isNaN(Number.parseInt(postId))) { + return new Response("Post ID is invalid", { status: 500 }); + } + await db + .delete(posts) + .where( + and(eq(posts.id, Number.parseInt(postId)), eq(posts.userId, user.id)) + ); + } + return redirect("/"); +}; diff --git a/app/routes/resources.tsx b/app/routes/resources.tsx deleted file mode 100644 index b65ad79..0000000 --- a/app/routes/resources.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import type { LoaderFunctionArgs, ActionFunctionArgs } from "@remix-run/node"; -import { Form, json, useLoaderData } from "@remix-run/react"; -import { drizzle } from "drizzle-orm/d1"; -import { resources } from "~/db/schema.server"; - -export async function action({ request, context }: ActionFunctionArgs) { - const env = context.cloudflare.env as Env; - const formData = await request.formData(); - const title = formData.get("title") as string; - const href = formData.get("href") as string; - const db = drizzle(env.DB); - - await db.insert(resources).values({ title, href }).execute(); - - return json({ message: "Resource added" }, { status: 201 }); -} - -export async function loader({ context }: LoaderFunctionArgs) { - const env = context.cloudflare.env as Env; - const db = drizzle(env.DB); - const resourceList = await db - .select({ - id: resources.id, - title: resources.title, - href: resources.href, - }) - .from(resources) - .orderBy(resources.id); - - return json({ - resourceList, - }); -} - -export default function ResourcesPage() { - const { resourceList } = useLoaderData(); - return ( -
-

Welcome to Remix (with Drizzle, Vite and Cloudflare D1)

-
    - {resourceList.map((resource) => ( -
  1. - - {resource.title} - -
  2. - ))} -
-
-
- -
-
- -
- -
-
- ); -} diff --git a/app/services/auth.server.ts b/app/services/auth.server.ts new file mode 100644 index 0000000..70b9eff --- /dev/null +++ b/app/services/auth.server.ts @@ -0,0 +1,76 @@ +import { Authenticator } from "remix-auth"; +import type { AppLoadContext } from "@remix-run/cloudflare"; + +import { GoogleStrategy } from "remix-auth-google"; +import { eq } from "drizzle-orm"; +import { users } from "~/libs/drizzle/schema"; +import { getDBClient } from "~/libs/drizzle/client.server"; + +export type User = { + name: string; + id: number; +}; + +let createCookieSessionStorage: typeof import("@remix-run/cloudflare").createCookieSessionStorage; + +if (import.meta.env.DEV) { + import("@remix-run/node").then((module) => { + createCookieSessionStorage = module.createCookieSessionStorage; + }); +} else { + import("@remix-run/cloudflare").then((module) => { + createCookieSessionStorage = module.createCookieSessionStorage; + }); +} + +let _authenticatedUser: Authenticator | null = null; + +export function getAuthenticator(context: AppLoadContext) { + if (_authenticatedUser === null) { + if (!createCookieSessionStorage) { + throw new Error("createCookieSessionStorage is not initialized"); + } + const sessionStorage = createCookieSessionStorage({ + cookie: { + name: "_session", + sameSite: "lax", + path: "/", + httpOnly: true, + secrets: [context.cloudflare.env.AUTH_SECRET], + secure: import.meta.env.PROD, + }, + }); + _authenticatedUser = new Authenticator(sessionStorage); + const googleStrategy = new GoogleStrategy( + { + clientID: context.cloudflare.env.GOOGLE_CLIENT_ID, + clientSecret: context.cloudflare.env.GOOGLE_CLIENT_SECRET, + callbackURL: `${context.cloudflare.env.GOOGLE_CALLBACK_BASE_URL}/auth/google/callback`, + }, + async ({ profile }) => { + const db = getDBClient(context.cloudflare.env.DB); + const exitsUser = await db + .select() + .from(users) + .where(eq(users.providerId, profile.id)) + .limit(1); + if (exitsUser.length === 0) { + const createUser = await db + .insert(users) + .values({ + provider: profile.provider, + providerId: profile.id, + name: profile.displayName, + icon: profile.photos[0].value, + }) + .returning() + .get(); + return { id: createUser.id, name: createUser.name }; + } + return { id: exitsUser[0].id, name: exitsUser[0].name }; + } + ); + _authenticatedUser.use(googleStrategy); + } + return _authenticatedUser; +} diff --git a/bun.lockb b/bun.lockb index 4d74fc7..1c83721 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..c71e02b --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,8 @@ +import type { Config } from "drizzle-kit"; + +export default { + schema: "./app/libs/drizzle/schema.ts", + out: "./app/libs/drizzle/migrations", + driver: "d1-http", + dialect: "sqlite", +} satisfies Config; diff --git a/package.json b/package.json index 4fb4e29..8798aa9 100644 --- a/package.json +++ b/package.json @@ -5,15 +5,17 @@ "type": "module", "scripts": { "build": "remix vite:build", - "deploy": "bun run build && wrangler pages deploy", "dev": "remix vite:dev", - "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", "start": "wrangler pages dev ./build/client", - "typecheck": "tsc", - "typegen": "wrangler types", - "preview": "bun run build && wrangler pages dev", - "cf-typegen": "wrangler types", - "db:update": "drizzle-kit generate --out ./app/db/migrations --schema ./app/db/schema.server.ts --dialect sqlite" + "lint:eslint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", + "lint:typecheck": "tsc --noEmit", + "cloudflare:deploy": "bun run build && wrangler pages deploy", + "cloudflare:type:generate": "wrangler types", + "db:sql:generate": "rm -rf ./app/libs/drizzle/migrations && drizzle-kit generate", + "db:migrate:local": "wrangler d1 migrations apply dartsroutine-db --local", + "db:migrate:remote": "wrangler d1 migrations apply dartsroutine-db --remote", + "db:clear:local": "wrangler d1 execute dartsroutine-db --local --file=./app/libs/drizzle/clear.sql", + "db:clear:remote": "wrangler d1 execute dartsroutine-db --remote --file=./app/libs/drizzle/clear.sql" }, "dependencies": { "@remix-run/cloudflare": "^2.11.1", @@ -22,7 +24,10 @@ "drizzle-orm": "^0.33.0", "isbot": "^4.1.0", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "remix-auth": "^3.7.0", + "remix-auth-form": "^1.5.0", + "remix-auth-google": "^2.0.0" }, "devDependencies": { "@cloudflare/workers-types": "^4.20240821.1", @@ -44,9 +49,9 @@ "typescript": "^5.1.6", "vite": "^5.1.0", "vite-tsconfig-paths": "^4.2.1", - "wrangler": "3.57.1" + "wrangler": "^3.57.1" }, "engines": { "node": ">=20.0.0" } -} \ No newline at end of file +} diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 4112818..a572d3f 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,6 +1,10 @@ -// Generated by Wrangler on Sat Aug 24 2024 16:59:23 GMT+0900 (日本標準時) +// Generated by Wrangler on Sat Aug 24 2024 17:51:19 GMT+0900 (日本標準時) // by running `wrangler types` interface Env { + AUTH_SECRET: string; + GOOGLE_CALLBACK_BASE_URL: string; + GOOGLE_CLIENT_ID: string; + GOOGLE_CLIENT_SECRET: string; DB: D1Database; } diff --git a/wrangler.toml b/wrangler.toml index c7c19dc..0a39786 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -7,7 +7,7 @@ pages_build_output_dir = "./build/client" binding = "DB" # i.e. available in your Worker on env.DB database_name = "dartsroutine-db" database_id = "3f34ce78-842f-47b5-98b6-cd30edae58ae" -migrations_dir = "./app/db/migrations" +migrations_dir = "./app/libs/drizzle/migrations" # Automatically place your workloads in an optimal location to minimize latency. # If you are running back-end logic in a Pages Function, running it closer to your back-end infrastructure