feat: add photos challenges

This commit is contained in:
Abel Stuker 2024-11-25 22:32:02 +01:00
parent 26039148eb
commit cd79fe01d9
74 changed files with 10797 additions and 0 deletions

22
photos/README.md Normal file
View File

@ -0,0 +1,22 @@
# Photos: The next big social media platform
This challenge contains 2 flags. The description for both flags can be the same.
## Text
I have built my own social media platform!
I really feel like there is a gap in the market for new social media apps.
My idea is super original: it's a website were people can upload and share photos.
I'm still missing some features, but you can have exclusive beta access :)
Have fun!
## Files
- Source code: [photos-source-code.zip](./photos-source-code.zip)
**ONLY GIVE THE ZIP FILE** The other files and directories may contain actual flags.
## How to deploy
N.A.

31
photos/SOLUTION-1.md Normal file
View File

@ -0,0 +1,31 @@
## Difficulty
Medium
## Category
Web
## How to solve
The goal of this challenge is to see the photo uploaded by the administrator.
It should be clear, given the source code, that a photo is automatically uploaded as part of the challenge.
When analyzing the communication between the frontend and the backend through the DevTools, or by inspecting the source code, you can see that there is no check if a user likes or un-likes a private photo they do not have access to. The ability to like private photos of other users is not a risk by itself, but the like endpoint also returns the updated photo.
Liking the adminstrator's photo (which has id 1) will return the caption of the photo, containing the first flag.
The following curl command returns the flag in the response:
> [!NOTE]
> You will need to create an account to use the /like route.
> To use the command below, replace <YOUR_COOKIE> with your session cookie.
```bash
curl --cookie "connect.sid=<YOUR_COOKIE>" --request POST http://localhost:3000/api/photos/1/like
```
## Flag
```
IGCTF{jUsT-a-sma11-data-l3ak}
```

24
photos/SOLUTION-2.md Normal file
View File

@ -0,0 +1,24 @@
## Difficulty
Hard
## Category
Web
## How to solve
The goal of this challenge is to see the photo uploaded by the administrator.
It should be clear, given the source code, that a photo is automatically uploaded as part of the challenge.
After finding the [first challenge](./SOLUTION-1.md), we have access to the caption of the administrator's photo, but not to the image itself. Unfortunately, the API route for getting the image itself checks for the correct permissions.
However, the frontend uses Next.js' `Image` component for showing images. This automatically optimizes and caches images. The application is served using nginx, which is configured in this case to "cache all the static assets". However, Next.js' static assets are served from `/_next/static/`, and not `/_next/`, which is also used by the image component at `/_next/image`. We can use this endpoint to get a cached version of the administrator's image, bypassing the authentication checks of the backend. You can also see this by playing around in the DevTools and seeing that the sources of the images go to `/_next/image` and not `/api/photo/...`.
To get the image associated with the flag, visit `http://localhost:3000/_next/image?url=%2Fapi%2Fphotos%2F1.jpeg`
## Flag
```
IGCTF{a-bIt-t00-0pt1mized}
```

View File

@ -0,0 +1,2 @@
DB_FILE_NAME=file:local.db
SESSION_SECRET=secret

197
photos/backend/.gitignore vendored Normal file
View File

@ -0,0 +1,197 @@
### Generated by gibo (https://github.com/simonwhitaker/gibo)
### https://raw.github.com/github/gitignore/4488915eec0b3a45b5c63ead28f286819c0917de/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### Generated by gibo (https://github.com/simonwhitaker/gibo)
### https://raw.github.com/github/gitignore/4488915eec0b3a45b5c63ead28f286819c0917de/Global/VisualStudioCode.gitignore
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
### Generated by gibo (https://github.com/simonwhitaker/gibo)
### https://raw.github.com/github/gitignore/4488915eec0b3a45b5c63ead28f286819c0917de/Node.gitignore
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Generated by gibo (https://github.com/simonwhitaker/gibo)
### https://raw.github.com/github/gitignore/4488915eec0b3a45b5c63ead28f286819c0917de/Global/Linux.gitignore
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
data/
local.db

44
photos/backend/Dockerfile Normal file
View File

@ -0,0 +1,44 @@
# https://www.tomray.dev/nestjs-docker-production#conclusion
FROM node:22-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /usr/src/app
COPY --chown=app:nodejs package*.json ./
COPY --chown=app:nodejs pnpm-lock.yaml ./
FROM base AS prod-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
FROM base AS build
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
COPY --chown=app:nodejs src ./src
COPY --chown=app:nodejs tsconfig.json drizzle.config.ts .
RUN pnpm run build
FROM base
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 app
COPY --chown=app:nodejs package*.json ./
COPY --chown=app:nodejs pnpm-lock.yaml ./
COPY --chown=app:nodejs --from=build /usr/src/app/node_modules ./node_modules
COPY --chown=app:nodejs --from=build /usr/src/app/dist ./dist
COPY --chown=app:nodejs drizzle ./drizzle
RUN chown app:nodejs /usr/src/app
EXPOSE 8000
USER app
RUN mkdir data
ENTRYPOINT ["node", "dist/src/app.js"]

View File

@ -0,0 +1,12 @@
import "dotenv/config";
import { defineConfig } from "drizzle-kit";
export default defineConfig({
out: "./drizzle",
schema: "./src/db/schema.ts",
dialect: "sqlite",
dbCredentials: {
url: process.env.DB_FILE_NAME!,
},
casing: "snake_case",
});

View File

@ -0,0 +1,23 @@
CREATE TABLE `photo_likes` (
`photo_id` integer NOT NULL,
`user_id` text NOT NULL,
PRIMARY KEY(`photo_id`, `user_id`),
FOREIGN KEY (`photo_id`) REFERENCES `photos`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `photos` (
`id` integer PRIMARY KEY NOT NULL,
`user_id` text(50) NOT NULL,
`caption` text(250),
`visible` integer DEFAULT false NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`username` text(50) NOT NULL,
`password` text(50) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);

View File

@ -0,0 +1,169 @@
{
"version": "6",
"dialect": "sqlite",
"id": "f9a16b4f-d676-4bd0-beea-0d038d4a8953",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"photo_likes": {
"name": "photo_likes",
"columns": {
"photo_id": {
"name": "photo_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"photo_likes_photo_id_photos_id_fk": {
"name": "photo_likes_photo_id_photos_id_fk",
"tableFrom": "photo_likes",
"tableTo": "photos",
"columnsFrom": [
"photo_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"photo_likes_user_id_users_id_fk": {
"name": "photo_likes_user_id_users_id_fk",
"tableFrom": "photo_likes",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"photo_likes_photo_id_user_id_pk": {
"columns": [
"photo_id",
"user_id"
],
"name": "photo_likes_photo_id_user_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"photos": {
"name": "photos",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"caption": {
"name": "caption",
"type": "text(250)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"visible": {
"name": "visible",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {},
"foreignKeys": {
"photos_user_id_users_id_fk": {
"name": "photos_user_id_users_id_fk",
"tableFrom": "photos",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1731899224964,
"tag": "0000_fast_rawhide_kid",
"breakpoints": true
}
]
}

View File

@ -0,0 +1,37 @@
{
"name": "backend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "tsx watch src/app.ts",
"build": "tsc"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "pnpm@9.12.3+sha512.cce0f9de9c5a7c95bef944169cc5dfe8741abfb145078c0d508b868056848a87c81e626246cb60967cbd7fd29a6c062ef73ff840d96b3c86c40ac92cf4a813ee",
"devDependencies": {
"@types/express": "^5.0.0",
"@types/express-session": "^1.18.0",
"@types/multer": "^1.4.12",
"@types/passport": "^1.0.17",
"@types/passport-local": "^1.0.38",
"drizzle-kit": "^0.28.1",
"tsx": "^4.19.2",
"typescript": "^5.6.3"
},
"dependencies": {
"@libsql/client": "^0.14.0",
"argon2": "^0.41.1",
"dotenv": "^16.4.5",
"drizzle-orm": "^0.36.3",
"express": "5",
"express-session": "^1.18.1",
"multer": "1.4.5-lts.1",
"passport": "^0.7.0",
"passport-local": "^1.0.0",
"postgres": "^3.4.5"
}
}

File diff suppressed because it is too large Load Diff

27
photos/backend/src/app.ts Normal file
View File

@ -0,0 +1,27 @@
import "dotenv/config";
import passport from "passport";
import express from "express";
import * as auth from "./services/auth";
import authRouter from "./routers/auth";
import photosRouter from "./routers/photos";
import { runMigrations } from "./db";
const app = express();
auth.init(app, passport);
app.use(express.json());
app.use(passport.session());
app.use("/auth", authRouter);
app.use("/photos", photosRouter);
async function prepare() {
if (process.env.NODE_ENV === "production") {
await runMigrations();
}
}
prepare().then(() =>
app.listen(8000, () => console.log("Server listening on port 8000."))
);

View File

@ -0,0 +1,22 @@
import "dotenv/config";
import { drizzle } from "drizzle-orm/libsql";
import * as schema from "./schema";
import { ExtractTablesWithRelations } from "drizzle-orm";
import { SQLiteTransaction } from "drizzle-orm/sqlite-core";
import { migrate } from "drizzle-orm/libsql/migrator";
const db = drizzle(process.env.DB_FILE_NAME!, { schema, casing: "snake_case" });
type Transaction = SQLiteTransaction<
"async",
any,
typeof schema,
ExtractTablesWithRelations<typeof schema>
>;
async function runMigrations() {
await migrate(db, { migrationsFolder: "./drizzle" });
}
export { schema, runMigrations, Transaction };
export default db;

View File

@ -0,0 +1,60 @@
import { relations } from "drizzle-orm";
import {
integer,
primaryKey,
sqliteTable,
text,
} from "drizzle-orm/sqlite-core";
import * as crypto from "node:crypto";
/** Users */
export const users = sqliteTable("users", {
id: text().notNull().primaryKey().$defaultFn(crypto.randomUUID),
username: text({ length: 50 }).notNull().unique(),
password: text({ length: 50 }).notNull(),
});
/** Photos */
export const photos = sqliteTable("photos", {
id: integer().notNull().primaryKey(),
userId: text({ length: 50 })
.notNull()
.references(() => users.id),
caption: text({ length: 250 }),
visible: integer({ mode: "boolean" }).notNull().default(false),
});
export const photosRelations = relations(photos, ({ one, many }) => ({
user: one(users, {
fields: [photos.userId],
references: [users.id],
}),
likes: many(photoLikes),
}));
/** Photo likes */
export const photoLikes = sqliteTable(
"photo_likes",
{
photoId: integer()
.notNull()
.references(() => photos.id),
userId: text()
.notNull()
.references(() => users.id),
},
(table) => ({ pk: primaryKey({ columns: [table.photoId, table.userId] }) })
);
export const photosLikesRelations = relations(photoLikes, ({ one }) => ({
user: one(users, {
fields: [photoLikes.userId],
references: [users.id],
}),
photo: one(photos, {
fields: [photoLikes.photoId],
references: [photos.id],
}),
}));

10
photos/backend/src/global.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
declare global {
namespace Express {
interface User {
id: string;
username: string;
}
}
}
export {};

View File

@ -0,0 +1,78 @@
import { Router } from "express";
import passport from "passport";
import * as users from "../services/users";
const router = Router();
/** Get user data */
router.get("/me", (req, res) => {
if (req.isAuthenticated()) {
res.send(req.user);
} else {
res.status(401).send();
}
});
/** Sign in */
router.post("/signin", passport.authenticate("local"), (req, res) => {
res.status(200).send(req.user);
});
/** Sign up */
router.post("/signup", async (req, res) => {
const { username, password } = req.body;
if (req.isAuthenticated()) {
res.status(400).send();
return;
}
if (
typeof username !== "string" ||
username.length < 2 ||
username.length > 50 ||
typeof password !== "string" ||
password.length < 2 ||
password.length > 50
) {
res.status(400).send();
return;
}
try {
const user = await users.create(username, password);
req.login(user, (err) => {
if (err) {
console.error(err);
res.status(500).send();
} else {
res.send(user);
}
});
} catch (e) {
if (
e instanceof Error &&
e.message == "UNIQUE constraint failed: users.username"
) {
res.status(400).send("This username is already taken.");
} else {
console.error(e);
res.status(500).send();
}
}
});
/** Sign out */
router.post("/signout", (req, res) => {
req.logout((err) => {
if (err) {
console.error(err);
res.status(500).send();
} else {
res.status(200).send();
}
});
});
export default router;

View File

@ -0,0 +1,125 @@
import { Router } from "express";
import multer from "multer";
import * as photos from "../services/photos";
const storage = multer.memoryStorage();
const upload = multer({
storage,
limits: { files: 1, fileSize: 5 * 1024 * 1024 },
});
const router = Router();
/** Get photos feed */
router.get("/", async (req, res) => {
let { offset, userId } = req.query;
let offsetNumber: number = 0;
let userIdString: string = "";
try {
if (typeof offset === "string") offsetNumber = parseInt(offset);
} catch {}
if (typeof userId === "string") userIdString = userId;
if (offsetNumber < 0) {
res.status(400).send();
return;
}
res.send(
await photos.getFeedForUserId(req.user?.id, offsetNumber, userIdString)
);
});
/** Get photo file */
router.get("/:id.jpeg", async (req, res) => {
const id = +req.params.id;
if (!isFinite(id)) {
res.status(400).send("Invalid id.");
return;
}
const photo = await photos.findById(id);
if (!photo) {
res.status(404).send("Photo not found.");
return;
}
if (photo.userId != req.user?.id && !photo.visible) {
res.status(401).send();
return;
}
res.sendFile(`./data/${id}.jpeg`, { root: process.cwd() });
});
/** Get photo data */
router.get("/:id", async (req, res) => {
const id = +req.params.id;
if (!isFinite(id)) {
res.status(400).send("Invalid id.");
return;
}
const photo = await photos.findByIdWithMetadata(req.user?.id, id);
if (!photo) {
res.status(404).send("Photo not found.");
return;
}
if (photo.userId != req.user?.id && !photo.visible) {
res.status(401).send();
return;
}
res.send(photo);
});
/** Upload photo */
router.post("/", upload.single("photo"), async (req, res) => {
if (!req.isAuthenticated()) {
res.status(401).send();
return;
}
const file = req.file;
if (!file || file.mimetype !== "image/jpeg") {
res.status(400).send("Unsupported file type.");
return;
}
const photo = await photos.create(
req.user.id,
file,
req.body.caption,
req.body.visible === "true"
);
res.send(photo);
});
/** Like or un-like a post */
router.post("/:id/like", async (req, res) => {
const id = parseInt(req.params.id);
if (!isFinite(id)) {
res.status(400).send("Invalid id.");
return;
}
if (!req.isAuthenticated()) {
res.status(401).send();
return;
}
const photo = await photos.updateLike(
req.user.id,
id,
req.body?.like === true
);
res.send(photo);
});
export default router;

View File

@ -0,0 +1,77 @@
import express from "express";
import { PassportStatic } from "passport";
import LocalStrategy from "passport-local";
import session from "express-session";
import * as argon2 from "argon2";
import * as users from "./users";
import db from "../db";
/** Deserializes a user from session data */
async function deserializeUser(
id: string,
done: (err: any, user?: Express.User) => void
) {
try {
const user = await users.findById(id);
if (!user) return done(new Error("User not found."));
done(undefined, user);
} catch (e) {
done(e);
}
}
/** Verifies user credentials */
async function verify(
username: string,
password: string,
cb: (
error: any,
user?: Express.User | false,
options?: LocalStrategy.IVerifyOptions
) => void
) {
const user = await db.query.users.findFirst({
where: (users, { eq }) => eq(users.username, username),
});
if (!user) return cb(undefined, false);
try {
if (await argon2.verify(user.password, password)) {
const { password, ...data } = user;
return cb(undefined, data);
} else {
return cb(undefined, false);
}
} catch {
return cb(undefined, false);
}
}
/** Initializes the authentication service */
export function init(app: express.Application, passport: PassportStatic) {
passport.serializeUser((user, done) => done(undefined, user.id));
passport.deserializeUser(deserializeUser);
passport.use(
new LocalStrategy.Strategy(
{
usernameField: "username",
passwordField: "password",
},
verify
)
);
app.use(
session({
secret: process.env.SESSION_SECRET!,
resave: true,
saveUninitialized: true,
cookie: {
httpOnly: true,
},
})
);
}

View File

@ -0,0 +1,122 @@
import { and, eq } from "drizzle-orm";
import db, { schema, Transaction } from "../db";
import * as fs from "node:fs/promises";
/** Finds a photo by its id */
export async function findById(id: number) {
return await db.query.photos.findFirst({
where: (photos, { eq }) => eq(photos.id, id),
});
}
/** Finds a photo by its id for a given user and returns additional metadata */
export async function findByIdWithMetadata(
userId: string | undefined,
id: number,
tx?: Transaction
) {
const photo = await (tx || db).query.photos.findFirst({
where: (photos, { eq }) => eq(photos.id, id),
with: {
user: {
columns: {
id: true,
username: true,
},
},
likes: true,
},
});
if (!photo) throw new Error("Photo not found.");
const { likes, ...data } = photo;
return {
...data,
likes: likes.length,
liked: !!likes.find((like) => like.userId === userId),
};
}
/** Creates a new photo */
export async function create(
userId: string,
file: Express.Multer.File,
caption?: string,
visible?: boolean
) {
return await db.transaction(async (tx) => {
const [photo] = await tx
.insert(schema.photos)
.values({ userId, caption, visible })
.returning();
await fs.writeFile(`./data/${photo.id}.jpeg`, file.buffer);
return photo;
});
}
/** Gets the photo for a given user */
export async function getFeedForUserId(
userId: string | undefined,
offset: number,
forUserId?: string
) {
const photos = await db.query.photos.findMany({
where: (photos, { or, eq }) =>
and(
or(
eq(photos.visible, true),
userId ? eq(photos.userId, userId) : undefined
),
forUserId ? eq(photos.userId, forUserId) : undefined
),
with: {
user: {
columns: {
id: true,
username: true,
},
},
likes: true,
},
offset,
limit: 10,
});
return photos.map(({ likes, ...photo }) => ({
...photo,
likes: likes.length,
liked: !!likes.find((like) => like.userId === userId),
}));
}
/** Updates the like status of a photo for a user */
export async function updateLike(
userId: string,
photoId: number,
like: boolean
) {
return await db.transaction(async (tx) => {
if (like) {
await tx
.insert(schema.photoLikes)
.values({ userId, photoId })
.onConflictDoNothing();
} else {
await tx
.delete(schema.photoLikes)
.where(
and(
eq(schema.photoLikes.photoId, photoId),
eq(schema.photoLikes.userId, userId)
)
);
}
return findByIdWithMetadata(userId, photoId, tx);
});
}

View File

@ -0,0 +1,30 @@
import db, { schema } from "../db";
import * as argon2 from "argon2";
/** Finds a user by their id */
export async function findById(id: string) {
return await db.query.users.findFirst({
where: (users, { eq }) => eq(users.id, id),
columns: { id: true, username: true },
});
}
/** Finds a user by their username */
export async function findByUsername(username: string) {
return await db.query.users.findFirst({
where: (users, { eq }) => eq(users.username, username),
columns: { id: true, username: true },
});
}
/** Creates a user */
export async function create(username: string, password: string) {
const hash = await argon2.hash(password);
const [user] = await db
.insert(schema.users)
.values({ username, password: hash })
.returning({ id: schema.users.id, username: schema.users.username });
return user;
}

View File

@ -0,0 +1,110 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "commonjs" /* Specify what module code is generated. */,
// "rootDir": "./", /* Specify the root folder within your source files. */
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./dist" /* Specify an output folder for all emitted files. */,
// "removeComments": true, /* Disable emitting comments. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
/* Type Checking */
"strict": true /* Enable all strict type-checking options. */,
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}

View File

@ -0,0 +1,33 @@
services:
nginx:
build:
context: ./nginx
ports:
- 3000:80
frontend:
build:
context: ./frontend
network_mode: service:nginx
backend:
build:
context: ./backend
environment:
- NODE_ENV=production
- DB_FILE_NAME=file:local.db
- SESSION_SECRET=secret
network_mode: service:nginx
init:
platform: linux/amd64
build:
context: ./init
restart: "on-failure"
depends_on:
nginx:
condition: service_started
environment:
- ADMIN_PASSWORD=supersecretpassword
- FLAG_1=NOT-THE-REAL-FLAG-1
network_mode: service:nginx

BIN
photos/fake-flag.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

BIN
photos/flag.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 622 KiB

View File

@ -0,0 +1,7 @@
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git

View File

@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}

40
photos/frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,40 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@ -0,0 +1,64 @@
# syntax=docker.io/docker/dockerfile:1
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED=1
RUN \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View File

@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks",
"extension": "@/components/ui/extension"
},
"iconLibrary": "lucide"
}

View File

@ -0,0 +1,14 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
rewrites: async () => [
{
source: "/api/:path*",
destination: "http://localhost:8000/:path*",
},
],
output: "standalone",
};
export default nextConfig;

View File

@ -0,0 +1,44 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-toast": "^1.2.2",
"@tanstack/react-query": "^5.60.5",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.460.0",
"next": "15.0.3",
"react": "19.0.0-rc-66855b96-20241106",
"react-dom": "19.0.0-rc-66855b96-20241106",
"react-dropzone": "^14.3.5",
"react-hook-form": "^7.53.2",
"sonner": "^1.7.0",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "15.0.3",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
},
"packageManager": "pnpm@9.12.3+sha512.cce0f9de9c5a7c95bef944169cc5dfe8741abfb145078c0d508b868056848a87c81e626246cb60967cbd7fd29a6c062ef73ff840d96b3c86c40ac92cf4a813ee"
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

View File

@ -0,0 +1,9 @@
import { SignInForm } from "@/components/signin-form";
export default function Page() {
return (
<div className="flex h-screen w-full items-center justify-center px-4">
<SignInForm />
</div>
);
}

View File

@ -0,0 +1,9 @@
import { SignUpForm } from "@/components/signup-form";
export default function Page() {
return (
<div className="flex h-screen w-full items-center justify-center px-4">
<SignUpForm />
</div>
);
}

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,72 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: Arial, Helvetica, sans-serif;
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@ -0,0 +1,44 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
import Providers from "./providers";
import Header from "@/components/header";
import { Toaster } from "@/components/ui/toaster";
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
weight: "100 900",
});
const geistMono = localFont({
src: "./fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
weight: "100 900",
});
export const metadata: Metadata = {
title: "Photos",
description: "Instagram 2.0",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased flex flex-col items-center`}
>
<div className="min-w-full md:min-w-[750px] px-6">
<Providers>
<Header />
{children}
</Providers>
<Toaster />
</div>
</body>
</html>
);
}

View File

@ -0,0 +1,32 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
export default function useMutateLike(id: number, liked: boolean) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
const response = await fetch(`/api/photos/${id}/like`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ like: !liked }),
});
if (!response.ok) throw new Error(response.statusText);
const newPhoto = await response.json();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
queryClient.setQueriesData({ queryKey: ["photos"] }, (data: any) => {
if (Array.isArray(data.pages)) {
return {
pages: data.pages.map((photos: App.Photo[]) =>
photos.map((photo) => (photo.id === id ? newPhoto : photo))
),
pageParams: data.pageParams,
};
} else {
return newPhoto;
}
});
},
});
}

View File

@ -0,0 +1,90 @@
"use client";
import { Button } from "@/components/ui/button";
import { useInfiniteQuery } from "@tanstack/react-query";
import React from "react";
import Photo from "@/components/photo";
import Link from "next/link";
import { LoaderCircleIcon } from "lucide-react";
import useAuth from "@/hooks/use-auth";
async function getPhotos({ pageParam }: { pageParam: number }) {
const response = await fetch(`/api/photos?offset=${pageParam}`);
if (!response.ok) throw new Error(response.statusText);
return (await response.json()) as App.Photo[];
}
const LIMIT = 10;
export default function Home() {
const auth = useAuth();
const photos = useInfiniteQuery({
queryKey: ["photos"],
queryFn: getPhotos,
initialPageParam: 0,
getNextPageParam: (page, pages) =>
page.length > LIMIT ? pages.length * LIMIT : undefined,
});
return (
<div className="font-[family-name:var(--font-geist-sans)] flex flex-col gap-8 pb-8">
<h1 className="text-2xl font-semibold">Feed</h1>
{photos.isError ? (
"error"
) : photos.isSuccess ? (
photos.data.pages.map((group, i) => (
<React.Fragment key={i}>
{group.map((photo) => (
<Photo key={photo.id} {...photo} />
))}
</React.Fragment>
))
) : (
<div className="flex flex-col items-center gap-4">
Loading... <LoaderCircleIcon className="size-12 animate-spin" />
</div>
)}
<div className="flex flex-col items-center">
<div>
{photos.hasNextPage ? (
<Button
onClick={() => photos.fetchNextPage()}
disabled={!photos.hasNextPage || photos.isFetchingNextPage}
>
{photos.isFetchingNextPage
? "Loading more..."
: photos.hasNextPage
? "Load More"
: "Nothing more to load"}
</Button>
) : !photos.isFetching ? (
<div className="flex flex-col items-center">
<span>There are no more photos :(</span>
{auth.user ? (
<>
<span>Why don&apos;t you upload one?</span>
<Button asChild className="mt-3">
<Link href="/upload">Upload photo</Link>
</Button>
</>
) : (
<>
<span>Create an account to upload photos.</span>
<Button asChild className="mt-3 w-full">
<Link href="/auth/signup">Sign up</Link>
</Button>
</>
)}
</div>
) : (
<></>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,72 @@
"use client";
import useMutateLike from "@/app/mutations/like";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import useAuth from "@/hooks/use-auth";
import { useQuery } from "@tanstack/react-query";
import Image from "next/image";
import { useParams } from "next/navigation";
export default function Page() {
const auth = useAuth();
const params = useParams<{ id: string }>();
const photo = useQuery({
queryKey: ["photos", params.id],
queryFn: async () => {
const response = await fetch(`/api/photos/${params.id}`);
if (!response.ok) throw new Error(response.statusText);
return (await response.json()) as App.Photo;
},
});
const mutation = useMutateLike(
photo.data?.id || 0,
photo.data?.liked || false
);
if (photo.isLoading) return <div>Loading...</div>;
if (photo.isError) {
if (photo.error instanceof Error) return <div>{photo.error.message}</div>;
return <div>Unknown error</div>;
}
return (
<div className="flex flex-col items-center">
<div className="w-fit">
<div className="flex flex-col items-center">
<div className="relative w-[75vw] aspect-square md:size-96 border rounded-sm shadow-sm">
<Image
src={`/api/photos/${params.id}.jpeg`}
alt=""
fill
className="object-scale-down"
/>
</div>
</div>
<h1 className="text-2xl font-semibold mt-6 w-fit">
Photo by {photo.data?.user?.username}
</h1>
<Label>Caption</Label>
<p className="w-fit">{photo.data?.caption}</p>
<div className="flex items-center justify-between">
<div>
<Label>Likes</Label>
<p>{photo.data?.likes}</p>
</div>
{!auth.isLoading && auth.user && (
<Button onClick={() => mutation.mutate()}>
{photo.data?.liked ? "Un-like" : "Like"}
</Button>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,96 @@
"use client";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import useAuth from "../../hooks/use-auth";
import { useRouter } from "next/navigation";
import React from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import Photo from "@/components/photo";
async function getPhotos({
queryKey,
pageParam,
}: {
queryKey: (string | undefined)[];
pageParam: number;
}) {
const response = await fetch(
`/api/photos?offset=${pageParam}&userId=${queryKey[1]}`
);
if (!response.ok) throw new Error(response.statusText);
return (await response.json()) as App.Photo[];
}
const LIMIT = 10;
export default function Page() {
const auth = useAuth();
const router = useRouter();
React.useEffect(() => {
if (!auth.isLoading && !auth.isError && auth.user === null)
router.push("/auth/signin");
}, [auth.isLoading, auth.isError, auth.user, router]);
const photos = useInfiniteQuery({
queryKey: ["photos", auth.user?.id],
queryFn: getPhotos,
enabled: !!auth.user,
initialPageParam: 0,
getNextPageParam: (page, pages) =>
page.length > LIMIT ? pages.length * LIMIT : undefined,
});
if (auth.isLoading) return <div>loading...</div>;
if (auth.isError) return <div>error</div>;
return (
<div className="flex flex-col items-center">
<Avatar className="size-44">
<AvatarFallback className="text-6xl">
{auth.user?.username.slice(0, 2)}
</AvatarFallback>
</Avatar>
<h1 className="text-3xl font-semibold mt-5">Your profile</h1>
<h1 className="text-2xl font-semibold mt-4">Photos</h1>
<div className="flex flex-col items-center gap-4 mt-4 pb-8 w-full">
{photos.isError
? "error"
: photos.isSuccess
? photos.data.pages.map((group, i) => (
<React.Fragment key={i}>
{group.map((photo) => (
<Photo key={photo.id} {...photo} />
))}
</React.Fragment>
))
: "loading...."}
<div className="flex flex-col items-center mt-4">
<div>
<Button
onClick={() => photos.fetchNextPage()}
disabled={!photos.hasNextPage || photos.isFetchingNextPage}
>
{photos.isFetchingNextPage
? "Loading more..."
: photos.hasNextPage
? "Load More"
: "Nothing more to load"}
</Button>
</div>
<div>
{photos.isFetching && !photos.isFetchingNextPage
? "Fetching..."
: null}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,50 @@
// In Next.js, this file would be called: app/providers.tsx
"use client";
// Since QueryClientProvider relies on useContext under the hood, we have to put 'use client' on top
import {
isServer,
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching immediately on the client
staleTime: 60 * 1000,
retry: 1,
},
},
});
}
let browserQueryClient: QueryClient | undefined = undefined;
function getQueryClient() {
if (isServer) {
// Server: always make a new query client
return makeQueryClient();
} else {
// Browser: make a new query client if we don't already have one
// This is very important, so we don't re-make a new client if React
// suspends during the initial render. This may not be needed if we
// have a suspense boundary BELOW the creation of the query client
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
}
export default function Providers({ children }: { children: React.ReactNode }) {
// NOTE: Avoid useState when initializing the query client if you don't
// have a suspense boundary between this and the code that may
// suspend because React will throw away the client on the initial
// render if it suspends and there is no boundary
const queryClient = getQueryClient();
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

View File

@ -0,0 +1,198 @@
"use client";
import { Button } from "@/components/ui/button";
import {
FileUploader,
FileUploaderContent,
FileUploaderItem,
FileInput,
} from "@/components/ui/extension/file-upload";
import { CloudUpload, Paperclip } from "lucide-react";
import React from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import useAuth from "@/hooks/use-auth";
const formSchema = z.object({
files: z
.array(
z.any().refine((file) => file.size < 5 * 1024 * 1024, {
message: "Photo size must be less than 5MB.",
})
)
.max(5, { message: "You can only upload one photo at a time." })
.min(1, { message: "A photo is required." }),
caption: z.string().max(250).optional(),
visible: z.boolean().optional(),
});
export default function Page() {
const auth = useAuth();
const queryClient = useQueryClient();
const router = useRouter();
React.useEffect(() => {
if (!auth.isLoading && !auth.isError && !auth.user)
router.push("/auth/signin");
}, [auth.isLoading, auth.isError, auth.user, router]);
const dropZoneConfig = {
maxFiles: 1,
maxSize: 1024 * 1024 * 4,
multiple: false,
accept: {
"image/jpeg": [".jpg", ".jpeg"],
},
};
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
caption: "",
visible: false,
files: [],
},
});
const mutation = useMutation({
mutationFn: async (data: z.infer<typeof formSchema>) => {
const body = new FormData();
body.append("photo", data.files[0]);
if (data.caption) body.append("caption", data.caption);
if (data.visible) body.append("visible", "true");
const response = await fetch("/api/photos", {
method: "POST",
body,
});
if (!response.ok) throw new Error(response.statusText);
return (await response.json()) as App.Photo;
},
});
async function onSubmit(values: z.infer<typeof formSchema>) {
try {
const photo = await mutation.mutateAsync(values);
queryClient.resetQueries({ queryKey: ["photos"] });
queryClient.setQueryData(["photos", photo.id], photo);
toast.success("Photo uploaded!");
router.push(`/photos/${photo.id}`);
} catch (e) {
if (e instanceof Error) {
form.setError("root", { message: e.message });
}
}
}
return (
<div>
<h1 className="text-2xl font-semibold mb-4">Upload a photo</h1>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-4">
<FormField
control={form.control}
name="files"
render={({ field }) => (
<FormItem className="grid gap-2">
<FileUploader
value={field.value}
onValueChange={field.onChange}
dropzoneOptions={dropZoneConfig}
className="relative bg-background rounded-lg p-2"
>
<FileInput className="outline-dashed outline-1 outline-white">
<div className="flex items-center justify-center flex-col pt-3 pb-4 w-full ">
<CloudUpload />
<p className="mb-1 text-sm text-gray-500 dark:text-gray-400">
<span className="font-semibold">Click to upload</span>
&nbsp; or drag and drop
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
Only JPG please
</p>
</div>
</FileInput>
<FileUploaderContent>
{field.value &&
field.value.length > 0 &&
field.value.map((file, i) => (
<FileUploaderItem key={i} index={i}>
<Paperclip className="h-4 w-4 stroke-current" />
<span>{file.name}</span>
</FileUploaderItem>
))}
</FileUploaderContent>
</FileUploader>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="caption"
render={({ field }) => (
<FormItem className="grid gap-2">
<FormLabel>Caption</FormLabel>
<FormControl>
<Textarea
placeholder="Describe your photo"
className="resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="visible"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4 gap-5">
<div className="space-y-0.5">
<FormLabel className="text-base">Make photo public</FormLabel>
<FormDescription>
Whether or not this photo should be visible for all users.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{form.formState.errors.root && (
<p className={"text-[0.8rem] font-medium text-destructive"}>
{form.formState.errors.root.message}
</p>
)}
<Button type="submit">Upload</Button>
</form>
</Form>
</div>
);
}

View File

@ -0,0 +1,65 @@
"use client";
import { usePathname } from "next/navigation";
import { Button } from "./ui/button";
import Link from "next/link";
import useAuth from "@/hooks/use-auth";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Avatar, AvatarFallback } from "./ui/avatar";
import { ImageIcon } from "lucide-react";
export default function Header() {
const pathname = usePathname();
const auth = useAuth();
if (pathname.startsWith("/auth")) return <></>;
return (
<header className="flex items-center justify-between w-full py-4">
<Link href="/" className="flex items-center gap-2">
<ImageIcon className="size-5 mb-1" /> Photos
</Link>
{auth.user ? (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost">
<Avatar className="size-8">
<AvatarFallback>
{auth.user.username.slice(0, 2)}
</AvatarFallback>
</Avatar>
<div>{auth.user.username}</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem asChild>
<Link href="/profile">Profile</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/upload">Upload a photo</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={auth.signOut}>
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : !auth.isLoading ? (
<Button asChild>
<Link href="/auth/signin">Sign in</Link>
</Button>
) : (
<div className="h-9" />
)}
</header>
);
}

View File

@ -0,0 +1,47 @@
import useMutateLike from "@/app/mutations/like";
import Image from "next/image";
import { Card } from "./ui/card";
import Link from "next/link";
import { Label } from "./ui/label";
import { Button } from "./ui/button";
import useAuth from "@/hooks/use-auth";
export default function Photo({ id, user, caption, likes, liked }: App.Photo) {
const auth = useAuth();
const mutation = useMutateLike(id, liked);
return (
<Card className="p-4 flex gap-6 w-full">
<div className="relative w-[25vw] md:size-64">
<Image
src={`/api/photos/${id}.jpeg`}
alt=""
fill
className="object-contain"
/>
</div>
<div className="flex flex-col flex-1">
<Link href={`/photos/${id}`}>
<h2>Photo by {user.username}</h2>
</Link>
<Label>Caption</Label>
<p>{caption ?? "This photo does not have a caption."}</p>
<div className="mt-auto flex justify-between">
<div className="flex flex-col">
<Label>Likes</Label>
{likes}
</div>
{!auth.isLoading && auth.user && (
<Button onClick={() => mutation.mutate()}>
{liked ? "Un-like" : "Like"}
</Button>
)}
</div>
</div>
</Card>
);
}

View File

@ -0,0 +1,155 @@
"use client";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { useMutation } from "@tanstack/react-query";
import useAuth from "@/hooks/use-auth";
import { useRouter } from "next/navigation";
import React from "react";
import { ImageIcon } from "lucide-react";
const formSchema = z.object({
username: z.string().min(2).max(50),
password: z.string().min(2).max(50),
});
export function SignInForm() {
const router = useRouter();
const auth = useAuth();
React.useEffect(() => {
if (auth.user) router.push("/");
}, [router, auth.user]);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
username: "",
password: "",
},
});
const mutation = useMutation({
mutationFn: async (data: z.infer<typeof formSchema>) => {
const response = await fetch("/api/auth/signin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error(response.statusText);
return (await response.json()) as App.User;
},
});
async function onSubmit(values: z.infer<typeof formSchema>) {
try {
const user = await mutation.mutateAsync(values);
auth.setUser(user);
} catch (e) {
if (e instanceof Error && e.message == "Unauthorized") {
form.setError("root", {
message: "Your username or password is incorrect.",
});
}
}
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-1 pl-3 font-semibold">
<ImageIcon className="size-5 mb-1" /> Photos
</div>
<Card className="mx-auto max-w-sm">
<CardHeader>
<CardTitle className="text-2xl">Sign in</CardTitle>
<CardDescription>
Enter your email below to login to your account
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem className="grid gap-2">
<FormLabel>Username</FormLabel>
<FormControl>
<Input
type="text"
placeholder="username"
autoComplete="username"
required
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem className="grid gap-2">
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
autoComplete="current-password"
placeholder="password"
required
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{form.formState.errors.root && (
<p className={"text-[0.8rem] font-medium text-destructive"}>
{form.formState.errors.root.message}
</p>
)}
<Button type="submit">Sign in</Button>
</form>
</Form>
<div className="mt-4 text-center text-sm">
Don&apos;t have an account?{" "}
<Link href="/auth/signup" className="underline">
Sign up
</Link>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,154 @@
"use client";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { useMutation } from "@tanstack/react-query";
import useAuth from "@/hooks/use-auth";
import { useRouter } from "next/navigation";
import React from "react";
import { ImageIcon } from "lucide-react";
const formSchema = z.object({
username: z.string().min(2).max(50),
password: z.string().min(2).max(50),
});
export function SignUpForm() {
const router = useRouter();
const auth = useAuth();
React.useEffect(() => {
if (auth.user) router.push("/");
}, [router, auth.user]);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
username: "",
password: "",
},
});
const mutation = useMutation({
mutationFn: async (data: z.infer<typeof formSchema>) => {
const response = await fetch("/api/auth/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok)
throw new Error((await response.text()) || response.statusText);
return (await response.json()) as App.User;
},
});
async function onSubmit(values: z.infer<typeof formSchema>) {
try {
const user = await mutation.mutateAsync(values);
auth.setUser(user);
} catch (e) {
if (e instanceof Error) {
form.setError("root", { message: e.message });
}
}
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-1 pl-3 font-semibold">
<ImageIcon className="size-5 mb-1" /> Photos
</div>
<Card className="mx-auto max-w-sm">
<CardHeader>
<CardTitle className="text-2xl">Sign up</CardTitle>
<CardDescription>
Create an account to upload your own photos!
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem className="grid gap-2">
<FormLabel>Username</FormLabel>
<FormControl>
<Input
type="text"
placeholder="username"
autoComplete="username"
required
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem className="grid gap-2">
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
autoComplete="new-password"
placeholder="password"
required
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{form.formState.errors.root && (
<p className={"text-[0.8rem] font-medium text-destructive"}>
{form.formState.errors.root.message}
</p>
)}
<Button type="submit">Sign up</Button>
</form>
</Form>
<div className="mt-4 text-center text-sm">
Already have an account?{" "}
<Link href="/auth/signin" className="underline">
Sign in
</Link>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@ -0,0 +1,364 @@
"use client";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import {
Dispatch,
SetStateAction,
createContext,
forwardRef,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import {
useDropzone,
DropzoneState,
FileRejection,
DropzoneOptions,
} from "react-dropzone";
import { toast } from "sonner";
import { Trash2 as RemoveIcon } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
type DirectionOptions = "rtl" | "ltr" | undefined;
type FileUploaderContextType = {
dropzoneState: DropzoneState;
isLOF: boolean;
isFileTooBig: boolean;
removeFileFromSet: (index: number) => void;
activeIndex: number;
setActiveIndex: Dispatch<SetStateAction<number>>;
orientation: "horizontal" | "vertical";
direction: DirectionOptions;
};
const FileUploaderContext = createContext<FileUploaderContextType | null>(null);
export const useFileUpload = () => {
const context = useContext(FileUploaderContext);
if (!context) {
throw new Error("useFileUpload must be used within a FileUploaderProvider");
}
return context;
};
type FileUploaderProps = {
value: File[] | null;
reSelect?: boolean;
onValueChange: (value: File[] | null) => void;
dropzoneOptions: DropzoneOptions;
orientation?: "horizontal" | "vertical";
};
/**
* File upload Docs: {@link: https://localhost:3000/docs/file-upload}
*/
export const FileUploader = forwardRef<
HTMLDivElement,
FileUploaderProps & React.HTMLAttributes<HTMLDivElement>
>(
(
{
className,
dropzoneOptions,
value,
onValueChange,
reSelect,
orientation = "vertical",
children,
dir,
...props
},
ref,
) => {
const [isFileTooBig, setIsFileTooBig] = useState(false);
const [isLOF, setIsLOF] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const {
accept = {
"image/*": [".jpg", ".jpeg", ".png", ".gif"],
},
maxFiles = 1,
maxSize = 4 * 1024 * 1024,
multiple = true,
} = dropzoneOptions;
const reSelectAll = maxFiles === 1 ? true : reSelect;
const direction: DirectionOptions = dir === "rtl" ? "rtl" : "ltr";
const removeFileFromSet = useCallback(
(i: number) => {
if (!value) return;
const newFiles = value.filter((_, index) => index !== i);
onValueChange(newFiles);
},
[value, onValueChange],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
if (!value) return;
const moveNext = () => {
const nextIndex = activeIndex + 1;
setActiveIndex(nextIndex > value.length - 1 ? 0 : nextIndex);
};
const movePrev = () => {
const nextIndex = activeIndex - 1;
setActiveIndex(nextIndex < 0 ? value.length - 1 : nextIndex);
};
const prevKey =
orientation === "horizontal"
? direction === "ltr"
? "ArrowLeft"
: "ArrowRight"
: "ArrowUp";
const nextKey =
orientation === "horizontal"
? direction === "ltr"
? "ArrowRight"
: "ArrowLeft"
: "ArrowDown";
if (e.key === nextKey) {
moveNext();
} else if (e.key === prevKey) {
movePrev();
} else if (e.key === "Enter" || e.key === "Space") {
if (activeIndex === -1) {
dropzoneState.inputRef.current?.click();
}
} else if (e.key === "Delete" || e.key === "Backspace") {
if (activeIndex !== -1) {
removeFileFromSet(activeIndex);
if (value.length - 1 === 0) {
setActiveIndex(-1);
return;
}
movePrev();
}
} else if (e.key === "Escape") {
setActiveIndex(-1);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[value, activeIndex, removeFileFromSet],
);
const onDrop = useCallback(
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
const files = acceptedFiles;
if (!files) {
toast.error("file error , probably too big");
return;
}
const newValues: File[] = value ? [...value] : [];
if (reSelectAll) {
newValues.splice(0, newValues.length);
}
files.forEach((file) => {
if (newValues.length < maxFiles) {
newValues.push(file);
}
});
onValueChange(newValues);
if (rejectedFiles.length > 0) {
for (let i = 0; i < rejectedFiles.length; i++) {
if (rejectedFiles[i].errors[0]?.code === "file-too-large") {
toast.error(
`File is too large. Max size is ${maxSize / 1024 / 1024}MB`,
);
break;
}
if (rejectedFiles[i].errors[0]?.message) {
toast.error(rejectedFiles[i].errors[0].message);
break;
}
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[reSelectAll, value],
);
useEffect(() => {
if (!value) return;
if (value.length === maxFiles) {
setIsLOF(true);
return;
}
setIsLOF(false);
}, [value, maxFiles]);
const opts = dropzoneOptions
? dropzoneOptions
: { accept, maxFiles, maxSize, multiple };
const dropzoneState = useDropzone({
...opts,
onDrop,
onDropRejected: () => setIsFileTooBig(true),
onDropAccepted: () => setIsFileTooBig(false),
});
return (
<FileUploaderContext.Provider
value={{
dropzoneState,
isLOF,
isFileTooBig,
removeFileFromSet,
activeIndex,
setActiveIndex,
orientation,
direction,
}}
>
<div
ref={ref}
tabIndex={0}
onKeyDownCapture={handleKeyDown}
className={cn(
"grid w-full focus:outline-none overflow-hidden ",
className,
{
"gap-2": value && value.length > 0,
},
)}
dir={dir}
{...props}
>
{children}
</div>
</FileUploaderContext.Provider>
);
},
);
FileUploader.displayName = "FileUploader";
export const FileUploaderContent = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ children, className, ...props }, ref) => {
const { orientation } = useFileUpload();
const containerRef = useRef<HTMLDivElement>(null);
return (
<div
className={cn("w-full px-1")}
ref={containerRef}
aria-description="content file holder"
>
<div
{...props}
ref={ref}
className={cn(
"flex rounded-xl gap-1",
orientation === "horizontal" ? "flex-raw flex-wrap" : "flex-col",
className,
)}
>
{children}
</div>
</div>
);
});
FileUploaderContent.displayName = "FileUploaderContent";
export const FileUploaderItem = forwardRef<
HTMLDivElement,
{ index: number } & React.HTMLAttributes<HTMLDivElement>
>(({ className, index, children, ...props }, ref) => {
const { removeFileFromSet, activeIndex, direction } = useFileUpload();
const isSelected = index === activeIndex;
return (
<div
ref={ref}
className={cn(
buttonVariants({ variant: "ghost" }),
"h-6 p-1 justify-between cursor-pointer relative",
className,
isSelected ? "bg-muted" : "",
)}
{...props}
>
<div className="font-medium leading-none tracking-tight flex items-center gap-1.5 h-full w-full">
{children}
</div>
<button
type="button"
className={cn(
"absolute",
direction === "rtl" ? "top-1 left-1" : "top-1 right-1",
)}
onClick={() => removeFileFromSet(index)}
>
<span className="sr-only">remove item {index}</span>
<RemoveIcon className="w-4 h-4 hover:stroke-destructive duration-200 ease-in-out" />
</button>
</div>
);
});
FileUploaderItem.displayName = "FileUploaderItem";
export const FileInput = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, children, ...props }, ref) => {
const { dropzoneState, isFileTooBig, isLOF } = useFileUpload();
const rootProps = isLOF ? {} : dropzoneState.getRootProps();
return (
<div
ref={ref}
{...props}
className={`relative w-full ${
isLOF ? "opacity-50 cursor-not-allowed " : "cursor-pointer "
}`}
>
<div
className={cn(
`w-full rounded-lg duration-300 ease-in-out
${
dropzoneState.isDragAccept
? "border-green-500"
: dropzoneState.isDragReject || isFileTooBig
? "border-red-500"
: "border-gray-300"
}`,
className,
)}
{...rootProps}
>
{children}
</div>
<Input
ref={dropzoneState.inputRef}
disabled={isLOF}
{...dropzoneState.getInputProps()}
className={`${isLOF ? "cursor-not-allowed" : ""}`}
/>
</div>
);
});
FileInput.displayName = "FileInput";

View File

@ -0,0 +1,178 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-[0.8rem] text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-[0.8rem] font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }

View File

@ -0,0 +1,129 @@
"use client"
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@ -0,0 +1,35 @@
"use client"
import { useToast } from "@/hooks/use-toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

21
photos/frontend/src/global.d.ts vendored Normal file
View File

@ -0,0 +1,21 @@
declare global {
namespace App {
interface User {
id: string;
username: string;
}
interface Photo {
id: number;
userId: number;
user: App.User;
caption?: string;
likes: number;
visible: boolean;
liked: boolean;
}
}
}
export {};

View File

@ -0,0 +1,41 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
async function getMe() {
const response = await fetch("/api/auth/me");
if (!response.ok) {
if (response.status === 401) return null;
throw new Error(response.statusText);
} else {
return (await response.json()) as App.User;
}
}
export default function useAuth() {
const queryClient = useQueryClient();
const { isLoading, isError, data } = useQuery({
queryKey: ["me"],
queryFn: getMe,
});
function setUser(user: App.User) {
queryClient.setQueryData(["me"], user);
queryClient.resetQueries({ queryKey: ["photos"] });
}
async function signOut() {
try {
await fetch("/api/auth/signout", { method: "POST" });
queryClient.invalidateQueries({ type: "all" });
} catch {}
}
return {
isLoading,
isError,
user: data,
setUser,
signOut,
};
}

View File

@ -0,0 +1,192 @@
"use client";
// Inspired by react-hot-toast library
import * as React from "react";
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
};
case "DISMISS_TOAST": {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { useToast, toast };

View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@ -0,0 +1,62 @@
import type { Config } from "tailwindcss";
export default {
darkMode: ["class"],
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
}
}
},
plugins: [require("tailwindcss-animate")],
} satisfies Config;

View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

5
photos/init/Dockerfile Normal file
View File

@ -0,0 +1,5 @@
FROM ghcr.io/puppeteer/puppeteer
COPY index.mjs flag.jpeg .
CMD ["node", "index.mjs"]

BIN
photos/init/flag.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 622 KiB

35
photos/init/index.mjs Normal file
View File

@ -0,0 +1,35 @@
import puppeteer from "puppeteer";
const BASE_URL = process.env.BASE_URL ?? "http://localhost";
const browser = await puppeteer.launch();
const page = await browser.newPage();
try {
await page.goto(`${BASE_URL}/auth/signup`);
await page.locator('[name="username"]').fill("admin");
await page.locator('[name="password"]').fill(process.env.ADMIN_PASSWORD);
await page.locator('[type="submit"]').click();
await new Promise((resolve) => setTimeout(resolve, 5000));
if (page.content.toString().includes("This username is already taken.")) {
console.log("admin user exists");
} else {
console.log("admin user created");
await page.goto(`${BASE_URL}/upload`);
const elementHandle = await page.$("input[type=file]");
await elementHandle.uploadFile("./flag.jpeg");
await page.locator('[name="caption"]').fill(process.env.FLAG_1);
await page.locator('[type="submit"]').click();
await new Promise((resolve) => setTimeout(resolve, 5000));
}
await browser.close();
} catch (e) {
console.error("Script failed to run due to an error", e);
process.exit(1);
}

3
photos/nginx/Dockerfile Normal file
View File

@ -0,0 +1,3 @@
FROM nginx
COPY ./nginx.conf /etc/nginx/conf.d/default.conf

28
photos/nginx/nginx.conf Normal file
View File

@ -0,0 +1,28 @@
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=static_cache:10m max_size=1g inactive=60m use_temp_path=off;
# From nextjs example nginx config
server {
listen 80;
server_name localhost;
# Proxy /api to backend
location /api/ {
proxy_pass http://localhost:8000/;
}
# Cache static assets
location /_next/ {
proxy_ignore_headers "Cache-Control" "Vary";
proxy_cache static_cache;
proxy_cache_key $uri;
proxy_cache_valid any 48h;
proxy_pass http://localhost:3000;
}
location / {
proxy_pass http://localhost:3000/;
}
}

Binary file not shown.