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
-
+ );
+ }
+ return (
+
);
}
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) => (
- -
-
- {resource.title}
-
-
- ))}
-
-
-
- );
-}
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