diff --git a/.gitignore b/.gitignore index 3d1a4a4..80e8a2e 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,23 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum diff --git a/app/api/save/route.ts b/app/api/save/route.ts index 64afa1b..fbf20ed 100644 --- a/app/api/save/route.ts +++ b/app/api/save/route.ts @@ -1,160 +1,8 @@ import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { useLastSavedTime } from "@/store/use-last-save"; -import { diff, Jimp } from 'jimp'; import { revalidatePath } from "next/cache"; - -export const runtime = "nodejs"; - -const SIMILARITY_THRESHOLD = 70; - -async function compareImages(img1Buffer: Buffer, img2Buffer: Buffer): Promise { - try { - const jimage1 = await Jimp.read(img1Buffer); - const jimage2 = await Jimp.read(img2Buffer); - - const { percent } = diff(jimage1, jimage2, 0.1); - const howSimilarImagesAre = 100 - (percent * 100); - - return howSimilarImagesAre; - } catch (error) { - console.error('compareImages error:', error); - throw error; - } -} - -async function checkImageSimilarity(newImageBuffer: Buffer, viz: "PUBLIC" | "PRIVATE"): Promise<{ isSimilar: boolean; whichImage?: string }> { - const existingImages = await prisma.userImage.findMany({ - where: { - visibility: viz, - }, - }); - - for (const image of existingImages) { - try { - const buffer = await fetch(image.cloudflareUrl).then((res) => res.arrayBuffer()); - const existingImageBuffer = Buffer.from(buffer); - - const similarityPercentage = await compareImages(newImageBuffer, existingImageBuffer); - - if (similarityPercentage > SIMILARITY_THRESHOLD) return { isSimilar: true, whichImage: image.id }; - else continue; - } catch (error) { - console.error(error); - throw new Error("Failed to compare images"); - } - } - - return { isSimilar: false }; -} - -export type UploadImageNonExisting = { - imageUrl: string; - identifier: string; -} - -export type UploadImageExisting = { - id: string; - cloudflareUrl: string; - identifier: string; - isOwner: boolean; -} - -async function uploadImageToCloudflare(file: FormData, userId: string, viz: "PUBLIC" | "PRIVATE"): Promise { - const identifier = file.get("identifier") as string; - const imageFile = file.get("file") as File; - file.delete("identifier"); - - const arrayBuffer = await imageFile.arrayBuffer(); - let fileBuffer = Buffer.from(arrayBuffer); - - const fileType = imageFile.type.split('/')[1].toUpperCase(); - - /* - // TODO: Remove this once we have a better way to handle the file type - if (!FILE_TYPES.includes(fileType as any)) { - fileBuffer = await sharp(fileBuffer) - .png() - .toBuffer(); - } - */ - - try { - const { isSimilar, whichImage } = await checkImageSimilarity(fileBuffer, viz); - - if (isSimilar) { - const image = await prisma.userImage.findUnique({ - where: { id: whichImage }, - }); - - if (!image) { - throw new Error("Image not found"); - } - - return { - ...image, - identifier, - isOwner: image.userId === userId, - } - } - - const processedFormData = new FormData(); - processedFormData.append("file", new Blob([fileBuffer]), `image.${fileType.toLowerCase()}`); - processedFormData.append("requireSignedURLs", "false"); - - const response = await fetch( - `https://api.cloudflare.com/client/v4/accounts/${process.env.CLOUDFLARE_ACCOUNT_ID}/images/v1`, - { - method: "POST", - headers: { - "Authorization": `Bearer ${process.env.CLOUDFLARE_BEARER_TOKEN}`, - }, - body: processedFormData, - } - ); - - if (!response.ok) { - throw new Error(response.statusText); - } - - const result = await response.json(); - return { - imageUrl: result.result.variants[0], - identifier, - }; - } catch (error) { - console.error(error); - throw new Error(error instanceof Error ? error.message : "Failed to upload image to Cloudflare"); - } -} - -async function saveOrUpdateUserImage(userId: string, imageUrl: string, identifier: string, visibility: "PUBLIC" | "PRIVATE"): Promise { - const existingImage = await prisma.userImage.findFirst({ - where: { userId, identifier }, - }); - - if (existingImage) { - await prisma.userImage.update({ - where: { id: existingImage.id }, - data: { cloudflareUrl: imageUrl, updatedAt: new Date(), visibility }, - }); - return "Image updated successfully"; - } else { - await prisma.userImage.create({ - data: { - userId, - cloudflareUrl: imageUrl, - visibility, - identifier, - createdAt: new Date(), - updatedAt: new Date(), - }, - }); - return "Image saved successfully"; - } -} - export async function POST(request: Request) { try { const userData = await auth(); @@ -164,7 +12,7 @@ export async function POST(request: Request) { return new Response("Unauthorized: User is not logged in", { status: 401 }); } - const session = await prisma.session.findFirstOrThrow({ + const session = await prisma.session.findFirst({ where: { userId }, }); @@ -174,23 +22,30 @@ export async function POST(request: Request) { const formData = await request.formData(); const visibility = formData.get("visibility") as "PUBLIC" | "PRIVATE"; - if (!visibility || !["PUBLIC", "PRIVATE"].includes(visibility)) throw new Error("Visibility must be provided"); + if (!visibility || !["PUBLIC", "PRIVATE"].includes(visibility)) { + return new Response("Invalid visibility. Must be PUBLIC or PRIVATE", { status: 400 }); + } + formData.append("userId", userId); - const maybeExists = await uploadImageToCloudflare(formData, userId, visibility); + const goResponse = await fetch(`${process.env.BACKEND_API}/api/v1/save`, { + method: "POST", + body: formData, + }); + + if (!goResponse.ok) { + throw new Error(await goResponse.text()); + } - if ('id' in maybeExists) { - const { id, cloudflareUrl, identifier, isOwner } = maybeExists; - // 204 status means duplicate image - return Response.json({ id, cloudflareUrl, identifier, isOwner, status: 204, type: "DUPLICATE", visibility, message: "Design already exists" }); - } else { - const { imageUrl, identifier } = maybeExists; - const message = await saveOrUpdateUserImage(userId, imageUrl, identifier, visibility); + const result = await goResponse.json(); + + if (result.type === "NEW_SAVE") { useLastSavedTime.getState().setLastSavedTime(new Date()); revalidatePath("/community"); revalidatePath(`/${userData.user.name}/profile`); - revalidatePath(`/${encodeURIComponent(userData?.user?.name??"")}/profile`); - return Response.json({ message, status: 200, type: "NEW_SAVE", visibility }); + revalidatePath(`/${encodeURIComponent(userData?.user?.name ?? "")}/profile`); } + + return Response.json(result); } catch (error: any) { console.error(error); return new Response(error.message, { status: 500 }); diff --git a/app/api/users/route.ts b/app/api/users/route.ts index 12fca98..671e3ae 100644 --- a/app/api/users/route.ts +++ b/app/api/users/route.ts @@ -1,22 +1,6 @@ import { auth, signOut } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; -async function deleteImageFromCloudflare(imageId: string): Promise { - const response = await fetch( - `https://api.cloudflare.com/client/v4/accounts/${process.env.CLOUDFLARE_ACCOUNT_ID}/images/v1/${imageId}`, - { - method: "DELETE", - headers: { - Authorization: `Bearer ${process.env.CLOUDFLARE_BEARER_TOKEN}`, - }, - } - ); - - if (!response.ok) { - throw new Error(`Failed to delete image from Cloudflare: ${response.statusText}`); - } -} - export async function DELETE(request: Request) { try { const userData = await auth(); @@ -29,29 +13,22 @@ export async function DELETE(request: Request) { if (authUser?.id !== userId) return Response.json({ error: "Unauthorized: Invalid userId" }, { status: 403 }); - const user = await prisma.user.findUnique({ - where: { id: userId }, - include: { - UserImage: true, + const response = await fetch(`${process.env.BACKEND_API}/api/v1/users/${userId}`, { + method: "DELETE", + headers: { + "X-Authenticated-User-ID": userId, }, }); - if (!user) return Response.json({ error: "User not found" }, { status: 404 }); - - const deleteImagePromises = user.UserImage.map(async (image) => { - const cloudflareId = image.cloudflareUrl.split("/")[4]; - if (cloudflareId) await deleteImageFromCloudflare(cloudflareId); - }); - - await Promise.all(deleteImagePromises); + if (!response.ok) { + throw new Error(`Failed to delete user`); + } await signOut({ redirect: false }); - await prisma.user.delete({ where: { id: userId } }); - - return Response.json({ message: "Account successfully deleted" }, { status: 200 }); + return new Response(JSON.stringify({ message: "Account successfully deleted" }), { status: 200 }); } catch (error: any) { console.error("Error deleting account:", error.message); - return Response.json({ error: error.message }, { status: 500 }); + return new Response(JSON.stringify({ error: error.message }), { status: 500 }); } } \ No newline at end of file diff --git a/cmd/api/api.go b/cmd/api/api.go new file mode 100644 index 0000000..62efb38 --- /dev/null +++ b/cmd/api/api.go @@ -0,0 +1,115 @@ +package main + +import ( + "context" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/dotcomnerd/seleneo/internal/repository" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" + + "go.uber.org/zap" + + "github.com/dotcomnerd/seleneo/internal/env" +) + +type application struct { + config config + logger *zap.SugaredLogger + repo repository.Storage +} + +type config struct { + addr string + env string + db dbConfig +} + +type dbConfig struct { + connStr string + maxIdleTime string + maxConn int + maxIdleConns int +} + +func (app *application) mount() http.Handler { + r := chi.NewRouter() + + r.Use(middleware.Recoverer) + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Logger) + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: []string{env.GetString("CORS_ALLOWED_ORIGIN", "http://localhost:3000")}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + // ExposedHeaders: []string{"Link"}, dont htink i need this ngl + AllowCredentials: false, + MaxAge: 300, + })) + r.Use(middleware.Timeout(30 * time.Second)) + + r.Route("/api", func(r chi.Router) { + r.Route("/v1", func(r chi.Router) { + r.Get("/health", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("we gon be ok, ok the hardest, istg")) + }) + + r.Post("/save", app.uploadImageHandler) + + r.Route("/users", func(r chi.Router) { + r.Delete("/{userId}", app.deleteUserHandler) + r.Get("/{userId}", app.getUser) + }) + }) + }) + + return r +} + +func (app *application) run(mux http.Handler) error { + + server := &http.Server{ + Addr: app.config.addr, + Handler: mux, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 15 * time.Second, + } + + shutdown := make(chan error) + + go func() { + quit := make(chan os.Signal, 1) + + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + s := <-quit + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + app.logger.Infow("signal got caught lacking", "signal", s.String()) + + shutdown <- server.Shutdown(ctx) + }() + + app.logger.Infow("server is running", "addr", app.config.addr) + + err := server.ListenAndServe() + if err != http.ErrServerClosed { + return err + } + + if err := <-shutdown; err != nil { + return err + } + + app.logger.Info("server stopped thankfully") + + return nil +} diff --git a/cmd/api/errors.go b/cmd/api/errors.go new file mode 100644 index 0000000..8c597de --- /dev/null +++ b/cmd/api/errors.go @@ -0,0 +1,15 @@ +package main + +import "net/http" + +func (app *application) internalServerError(w http.ResponseWriter, r *http.Request, err error) { + app.logger.Errorw("internal error", "method", r.Method, "path", r.URL.Path, "error", err.Error()) + + writeJsonError(w, http.StatusInternalServerError, "the server encountered a problem") +} + +func (app *application) notFoundResponse(w http.ResponseWriter, r *http.Request, err error) { + app.logger.Warnf("not found error", "method", r.Method, "path", r.URL.Path, "error", err.Error()) + + writeJsonError(w, http.StatusNotFound, "not found") +} \ No newline at end of file diff --git a/cmd/api/json.go b/cmd/api/json.go new file mode 100644 index 0000000..9997cb0 --- /dev/null +++ b/cmd/api/json.go @@ -0,0 +1,35 @@ +package main + +import ( + "encoding/json" + "net/http" + "github.com/go-playground/validator/v10" +) + +var Validate *validator.Validate // good library for validating data + +func init() { + Validate = validator.New(validator.WithRequiredStructEnabled()) +} + +func writeJSON(w http.ResponseWriter, status int, data any) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + return json.NewEncoder(w).Encode(data) +} + +func writeJsonError(w http.ResponseWriter, status int, message string) error { + type json struct { + Error string `json:"error"` + } + + return writeJSON(w, status, &json{Error: message}) +} + +func (app *application) jsonResponse(w http.ResponseWriter, status int, data any) error { + type json struct { + Data any `json:"data"` + } + + return writeJSON(w, status, &json{Data: data}) +} \ No newline at end of file diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..2150231 --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "fmt" + + "github.com/dotcomnerd/seleneo/internal/db" + "github.com/dotcomnerd/seleneo/internal/env" + "github.com/dotcomnerd/seleneo/internal/repository" + "github.com/joho/godotenv" + + "go.uber.org/zap" +) + +func main() { + err := godotenv.Load() + if err != nil { + fmt.Println("Error loading .env file") + } + + cfg := config{ + addr: env.GetString("API_ADDR", ":8080"), + env: env.GetString("ENV", "development"), //might not be needed (find a way to switch to vault soon) + db: dbConfig{ + connStr: env.GetString("DB_ADDR", "postgresql://devinechinemere:postgres123@localhost:5432/seleneo_db?sslmode=disable"), + maxIdleTime: env.GetString("DB_MAX_IDLE_TIME", "15m"), + maxConn: env.GetInt("DB_MAX_CONN", 25), + maxIdleConns: env.GetInt("DB_MAX_IDLE_CONN", 25), + }, + } + + logger := zap.Must(zap.NewProduction()).Sugar() + defer logger.Sync() + + db, err := db.New(cfg.db.connStr, cfg.db.maxIdleTime, cfg.db.maxConn, cfg.db.maxIdleConns) + if err != nil { + logger.Fatalf("cannot connect to db: %v", err) + } + + defer db.Close() + logger.Info("database connection established") + + repo := repository.New(db) + + app := &application{ + config: cfg, + logger: logger, + repo: repo, + } + + mux := app.mount() + + logger.Fatal(app.run(mux)) +} diff --git a/cmd/api/userImageController.go b/cmd/api/userImageController.go new file mode 100644 index 0000000..b1c5c7d --- /dev/null +++ b/cmd/api/userImageController.go @@ -0,0 +1,118 @@ +package main + +import ( + "fmt" + "io" + "net/http" + "strings" + + "github.com/dotcomnerd/seleneo/internal/repository" + "github.com/dotcomnerd/seleneo/internal/repository/cloudflare" + "github.com/dotcomnerd/seleneo/internal/util" +) + +// TODO: put this into a service layer PLEASE +func (app *application) uploadImageHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + maxBytes := 10 * 1024 * 1024 + err := r.ParseMultipartForm(int64(maxBytes)) + if err != nil { + writeJsonError(w, http.StatusBadRequest, "Unable to parse form data") + return + } + + userID := r.FormValue("userId") + identifier := r.FormValue("identifier") + visibility := repository.Visibility(r.FormValue("visibility")) + if userID == "" || identifier == "" || (visibility != repository.VisibilityPrivate && visibility != repository.VisibilityPublic) { + writeJsonError(w, http.StatusBadRequest, "Missing required fields") + return + } + + file, header, err := r.FormFile("file") + if err != nil { + writeJsonError(w, http.StatusBadRequest, "file not found") + return + } + defer file.Close() + + fileBuffer, err := io.ReadAll(file) + if err != nil { + writeJsonError(w, http.StatusInternalServerError, "error reading file") + return + } + + fileType := header.Header.Get("Content-Type") + fileExt := strings.Split(fileType, "/")[1] + if !isValidFileType(fileExt) { + writeJsonError(w, http.StatusBadRequest, "invalid file type. Only PNG and JPG allowed") + return + } + + pHash, err := util.CalculateImageHash(fileBuffer) + if err != nil { + app.logger.Errorf("error calculating image hash:", err) + writeJsonError(w, http.StatusInternalServerError, "Server Error") + return + } + + dupImg, isOwner, err := app.repo.ImageHash.FindSimilarImage(ctx, pHash, userID) + if err != nil && err.Error() != "image found" { + app.logger.Errorf("error finding similar image:", err) + app.jsonResponse(w, http.StatusInternalServerError, map[string]interface{}{ + "message": "Server Error", + "type": "SERVER_ERROR", + }) + return + } else if err != nil && err.Error() == "image found" { + // TODO: Make custom error for this, also make this return this into data instead of wrapping resp in nest json + app.jsonResponse(w, http.StatusOK, map[string]interface{}{ + "id": dupImg.ID, + "cloudflareUrl": dupImg.CloudflareURL, + "identifier": identifier, + "type": "DUPLICATE", + "isOwner": isOwner, + }) + return + } + + filename := fmt.Sprintf("image-%s.%s", userID, fileExt) + imageURL, err := cloudflare.UploadImageToCloudflare(ctx, fileBuffer, fileType, filename) + if err != nil { + writeJsonError(w, http.StatusInternalServerError, "error uploading image to cloudflare") + return + } + + msg, err := app.repo.UserImage.SaveOrUpdateUserImage(ctx, userID, imageURL, identifier, pHash, visibility) + if err != nil { + cloudflare.DeleteImageFromCloudflare(imageURL) + app.logger.Errorf("error saving image:", err) + writeJsonError(w, http.StatusInternalServerError, "error saving image") + return + } + + app.jsonResponse(w, http.StatusOK, map[string]interface{}{ + "message": msg, + "type": "NEW_SAVE", + "status": http.StatusOK, + "visibility": visibility, + }) +} + +type FileType string + +const ( + FileTypePNG FileType = "PNG" + FileTypeJPG FileType = "JPG" +) + +// i like my helpers and this will be used elsewhere (i hope) +func isValidFileType(fileType string) bool { + switch FileType(strings.ToUpper(fileType)) { + case FileTypePNG, FileTypeJPG: + return true + default: + return false + } +} diff --git a/cmd/api/usersController.go b/cmd/api/usersController.go new file mode 100644 index 0000000..bbd1fb0 --- /dev/null +++ b/cmd/api/usersController.go @@ -0,0 +1,48 @@ +package main + +import ( + "net/http" + "github.com/go-chi/chi/v5" + +) + +func (app *application) getUser(w http.ResponseWriter, r *http.Request) { + userId := chi.URLParam(r, "userId") + + user, err := app.repo.User.GetUserById(r.Context(), userId) + if err != nil { + app.internalServerError(w, r, err) + return + } + + if user == nil { + app.notFoundResponse(w, r, err) + return + } + + app.jsonResponse(w, http.StatusOK, user) +} + +func (app *application) deleteUserHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + userID := chi.URLParam(r, "userId") + if userID == "" { + writeJsonError(w, http.StatusBadRequest, "User ID is required") + return + } + + authUserID := r.Header.Get("X-Authenticated-User-ID") + if authUserID == "" || authUserID != userID { + writeJsonError(w, http.StatusForbidden, "Unauthorized: Invalid userId") + return + } + + err := app.repo.User.DeleteUser(ctx, userID) + if err != nil { + app.internalServerError(w, r, err) + return + } + + app.jsonResponse(w, http.StatusOK, map[string]string{"message": "Account successfully deleted"}) +} diff --git a/cmd/migrate/seed/main.go b/cmd/migrate/seed/main.go new file mode 100644 index 0000000..fdf6bcd --- /dev/null +++ b/cmd/migrate/seed/main.go @@ -0,0 +1,26 @@ +package main + +import ( + "log" + + "github.com/dotcomnerd/seleneo/internal/db" + "github.com/dotcomnerd/seleneo/internal/env" + "github.com/dotcomnerd/seleneo/internal/repository" +) + +func main() { + addr := env.GetString("DB_ADDR", "postgresql://devinechinemere:postgres123@localhost:5432/seleneo_db?sslmode=disable") + conn, err := db.New(addr, "15m", 3, 3) + if err != nil { + log.Fatal(err) + } + + defer conn.Close() + + store := repository.New(conn) + + _ = store + + // TODO: implement dummy data + // db.Seed(store, conn) +} diff --git a/components/studio/export/handlers.tsx b/components/studio/export/handlers.tsx index a003876..30056fb 100644 --- a/components/studio/export/handlers.tsx +++ b/components/studio/export/handlers.tsx @@ -35,8 +35,6 @@ export interface DuplicateResponse { id: string; identifier: string; isOwner: boolean; - status: number; - message: string; } export interface NewSaveResponse { @@ -127,13 +125,13 @@ export function ExportActions({ quality, fileType, sessionStatus }: ExportAction setIsSaving(true); try { - const snapshot = await createSnapshot(fileType, quality, scaleFactor); + const snapshot = await createSnapshot("PNG", quality, scaleFactor); if (!snapshot) return; const formData: FormData = new FormData(); - //i dont like this but im on a time crunch - const snapshotBlob: Blob = typeof snapshot === 'string' ? new Blob([snapshot], { type: 'image/webp' }) : snapshot; + //i STILL dont like this + const snapshotBlob: Blob = typeof snapshot === 'string' ? new Blob([snapshot], { type: 'image/png' }) : snapshot; // TODO: Make this global state so that if the image is uploaded successfully, it is added 2 the list of identifiers const identifier = crypto.randomUUID(); @@ -155,7 +153,7 @@ export function ExportActions({ quality, fileType, sessionStatus }: ExportAction throw new Error('Failed to upload image'); } - const data: SuccessResponse = await response.json(); + const data: SuccessResponse = (await response.json()).data; if (data.type === 'DUPLICATE') { setExistingImage(data); diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..72b27cd --- /dev/null +++ b/go.mod @@ -0,0 +1,34 @@ +module github.com/dotcomnerd/seleneo + +go 1.22.1 + +require ( + github.com/go-chi/chi/v5 v5.2.0 + github.com/go-chi/cors v1.2.1 + github.com/go-playground/validator/v10 v10.24.0 + github.com/google/uuid v1.6.0 + github.com/joho/godotenv v1.5.1 + github.com/lib/pq v1.10.9 + go.uber.org/zap v1.27.0 +) + +require github.com/stretchr/testify v1.10.0 // indirect + +require ( + github.com/cloudflare/cloudflare-go/v4 v4.0.0 + github.com/corona10/goimagehash v1.1.0 + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect + github.com/tidwall/gjson v1.14.4 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/crypto v0.32.0 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.21.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e03a214 --- /dev/null +++ b/go.sum @@ -0,0 +1,60 @@ +github.com/cloudflare/cloudflare-go/v4 v4.0.0 h1:qVtUvfsnH2n7aCZOIapbiE3w/FPNrHp7s578OLIdbo8= +github.com/cloudflare/cloudflare-go/v4 v4.0.0/go.mod h1:XcYpLe7Mf6FN87kXzEWVnJ6z+vskW/k6eUqgqfhFE9k= +github.com/corona10/goimagehash v1.1.0 h1:teNMX/1e+Wn/AYSbLHX8mj+mF9r60R1kBeqE9MkoYwI= +github.com/corona10/goimagehash v1.1.0/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= +github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= +github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg= +github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= +github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..1a85aa2 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,38 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "time" + + _ "github.com/lib/pq" +) + +func New(connStr, maxIdleTime string, maxConn, maxIdleConns int) (*sql.DB, error) { + db, err := sql.Open("postgres", connStr) + if err != nil { + fmt.Println("Error opening connection to db: ", err) + return nil, err + } + + db.SetMaxOpenConns(maxConn) + db.SetMaxIdleConns(maxIdleConns) + + idleTime, err := time.ParseDuration(maxIdleTime) + if err != nil { + return nil, err + } + + db.SetConnMaxIdleTime(idleTime) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + + defer cancel() + + if err := db.PingContext(ctx); err != nil { + return nil, err + } + + return db, nil +} \ No newline at end of file diff --git a/internal/env/env.go b/internal/env/env.go new file mode 100644 index 0000000..d6e6f9a --- /dev/null +++ b/internal/env/env.go @@ -0,0 +1,43 @@ +package env + +import ( + "os" + "strconv" +) + +func GetString(key, fallback string) string { + val, ok := os.LookupEnv(key) + if !ok { + return fallback + } + + return val +} + +func GetInt(key string, fallback int) int { + val, ok := os.LookupEnv(key) + if !ok { + return fallback + } + + newVal, err := strconv.Atoi(val) + if err != nil { + return fallback + } + + return newVal +} + +func GetBool(key string, fallback bool) bool { + val, ok := os.LookupEnv(key) + if !ok { + return fallback + } + + boolVal, err := strconv.ParseBool(val) + if err != nil { + return fallback + } + + return boolVal +} \ No newline at end of file diff --git a/internal/repository/cloudflare/cloudflare.go b/internal/repository/cloudflare/cloudflare.go new file mode 100644 index 0000000..3c232b9 --- /dev/null +++ b/internal/repository/cloudflare/cloudflare.go @@ -0,0 +1,70 @@ +package cloudflare + +import ( + "bytes" + "context" + "fmt" + "strings" + "time" + + "github.com/cloudflare/cloudflare-go/v4" + "github.com/cloudflare/cloudflare-go/v4/images" + "github.com/cloudflare/cloudflare-go/v4/option" + + "github.com/dotcomnerd/seleneo/internal/env" +) + +func UploadImageToCloudflare(ctx context.Context, fileBuffer []byte, fileType, filename string) (string, error) { + accountID := env.GetString("CLOUDFLARE_ACCOUNT_ID", "GET_YOUR_OWN_CLOUDFLARE_ID") + apiToken := env.GetString("CLOUDFLARE_API_TOKEN", "GET_YOUR_OWN_CLOUDFLARE_API_KEY") + + api := cloudflare.NewClient( + option.WithAPIToken(apiToken), + ) + + reader := bytes.NewReader(fileBuffer) + file := cloudflare.FileParam(reader, filename, fileType) + + image, err := api.Images.V1.New(ctx, images.V1NewParams{ + AccountID: cloudflare.F(accountID), + File: cloudflare.F[any](file), + }) + + if err != nil { + return "", fmt.Errorf("failed to upload image: %w", err) + } + + return image.Variants[0], nil +} + +func DeleteImageFromCloudflare(imageURL string) error { + accountID := env.GetString("CLOUDFLARE_ACCOUNT_ID", "GET_YOUR_OWN_CLOUDFLARE_ID") + apiToken := env.GetString("CLOUDFLARE_API_TOKEN", "GET_YOUR_OWN_CLOUDFLARE_API_KEY") + + api := cloudflare.NewClient( + option.WithAPIToken(apiToken), + ) + + imageId := strings.Split(imageURL, "/")[4] + fmt.Printf("Image ID: %s\n", imageId) + + // cloudflare timeout is 15 seconds anyway, but this is just a nice precausion + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + _, err := api.Images.V1.Delete(ctx, imageId, images.V1DeleteParams{ + AccountID: cloudflare.F(accountID), + }) + if err != nil { + if strings.Contains(err.Error(), "404 Not Found") { + fmt.Println("Image not found on Cloudflare, skipping deletion") + return nil + } + return fmt.Errorf("failed to delete image: %w", err) + } + + // TODO: find a way to get errors on response + // I still hate this shitty ahh cloudflare API with my whole SOUL + + return nil +} diff --git a/internal/repository/imagehash.go b/internal/repository/imagehash.go new file mode 100644 index 0000000..7a55035 --- /dev/null +++ b/internal/repository/imagehash.go @@ -0,0 +1,49 @@ +package repository + +import ( + "context" + "database/sql" + "errors" +) + +type ImageHash struct { + ID string `json:"id"` + ImageID string `json:"imageId"` + PerceptualHash string `json:"perceptualHash"` +} + +type ImageHashRepo struct { + db *sql.DB +} + +// use transactions for this? +func (ih *ImageHashRepo) FindSimilarImage(ctx context.Context, pHash, currUserId string) (*UserImage, bool, error) { + var imageId string + var ownerId string + + err := ih.db.QueryRowContext(ctx, `SELECT "imageId" FROM "ImageHash" WHERE "perceptualHash" = $1`, pHash). + Scan(&imageId) + + if err == sql.ErrNoRows { + return nil, false, nil + } else if err != nil { + return nil, false, err + } + + // im usually against calling someone elses table from another repository but i will fix this later + // TODO: add this logic to the UserImageRepo, not here. and return just the imageId + var image UserImage + err = ih.db.QueryRowContext(ctx, ` + SELECT id, "userId", "cloudflareUrl", identifier, "createdAt", "updatedAt", visibility + FROM "UserImage" + WHERE id = $1`, imageId). + Scan(&image.ID, &ownerId, &image.CloudflareURL, &image.Identifier, &image.CreatedAt, &image.UpdatedAt, &image.Visibility) + + if err == sql.ErrNoRows { + return nil, false, nil + } else if err != nil { + return nil, false, err + } + + return &image, ownerId == image.UserID, errors.New("image found") +} diff --git a/internal/repository/storage.go b/internal/repository/storage.go new file mode 100644 index 0000000..3b9f815 --- /dev/null +++ b/internal/repository/storage.go @@ -0,0 +1,51 @@ +package repository + +import ( + "context" + "database/sql" + "time" +) + +var( + QueryTimeout = 5 * time.Second + DeleteQueryTimeout = 15 * time.Second +) + +type Storage struct { + User interface{ + GetUserById(context.Context, string) (*User, error) + DeleteUser(context.Context, string) error + } + UserImage interface{ + SaveOrUpdateUserImage(context.Context, string, string, string, string, Visibility) (string, error) + FindByIdentifier(context.Context, string, string) (*UserImage, error) + } + ImageHash interface { + FindSimilarImage(context.Context, string, string) (*UserImage, bool, error) + } + +} + +// TODO: rewrite with gorm instead of sql.DB +func New(db *sql.DB) Storage { + return Storage{ + User: &UserRepo{db}, + UserImage: &UserImageRepo{db}, + ImageHash: &ImageHashRepo{db}, + } +} + +// for transactions 😀 +func withTx (db *sql.DB, ctx context.Context, f func(tx *sql.Tx) error) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + + if err := f(tx); err != nil { + tx.Rollback() + return err + } + + return tx.Commit() +} \ No newline at end of file diff --git a/internal/repository/user.go b/internal/repository/user.go new file mode 100644 index 0000000..f9da06d --- /dev/null +++ b/internal/repository/user.go @@ -0,0 +1,99 @@ +package repository + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/dotcomnerd/seleneo/internal/repository/cloudflare" +) + +type User struct { + ID string `json:"id"` + Name *string `json:"name,omitempty"` + Email *string `json:"email,omitempty"` + EmailVerified *time.Time `json:"emailVerified,omitempty"` + Image *string `json:"image,omitempty"` +} + +type UserRepo struct { + db *sql.DB +} + +func (u *UserRepo) GetUserById(ctx context.Context, id string) (*User, error) { + query := ` + SELECT id, name, email, "emailVerified", image + FROM "User" + WHERE "id" = $1 + ` + + ctx, cancel := context.WithTimeout(ctx, DeleteQueryTimeout) + defer cancel() + + user := &User{} + err := u.db.QueryRowContext(ctx, query, id).Scan( + &user.ID, + &user.Name, + &user.Email, + &user.EmailVerified, + &user.Image, + ) + + if err == sql.ErrNoRows { + return nil, fmt.Errorf("user with ID %s not found", id) + } else if err != nil { + return nil, err + } + + return user, nil +} + +func (u *UserRepo) DeleteUser(ctx context.Context, id string) error { + return withTx(u.db, ctx, func(tx *sql.Tx) error { + query := `SELECT "cloudflareUrl" FROM "UserImage" WHERE "userId" = $1` + rows, err := tx.QueryContext(ctx, query, id) + if err != nil { + return err + } + defer rows.Close() + + var cloudflareURLs []string + for rows.Next() { + var imageURL string + if err := rows.Scan(&imageURL); err != nil { + return err + } + cloudflareURLs = append(cloudflareURLs, imageURL) + } + + _, err = tx.ExecContext(ctx, `DELETE FROM "ImageHash" WHERE "imageId" IN (SELECT "id" FROM "UserImage" WHERE "userId" = $1)`, id) + if err != nil { + return err + } + + for _, imageURL := range cloudflareURLs { + if err := cloudflare.DeleteImageFromCloudflare(imageURL); err != nil { + return err + } + } + + _, err = tx.ExecContext(ctx, `DELETE FROM "UserImage" WHERE "userId" = $1`, id) + if err != nil { + return err + } + + // potential delete, might be handled by next-auth + _, err = tx.ExecContext(ctx, `DELETE FROM "Session" WHERE "userId" = $1`, id) + if err != nil { + return err + } + + _, err = tx.ExecContext(ctx, `DELETE FROM "User" WHERE id = $1`, id) + if err != nil { + return err + } + + return nil + }) +} diff --git a/internal/repository/userimage.go b/internal/repository/userimage.go new file mode 100644 index 0000000..5262f08 --- /dev/null +++ b/internal/repository/userimage.go @@ -0,0 +1,96 @@ +package repository + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/google/uuid" +) + +type Visibility string + +const ( + VisibilityPublic Visibility = "PUBLIC" + VisibilityPrivate Visibility = "PRIVATE" +) + +type UserImage struct { + ID string `json:"id"` + Identifier string `json:"identifier"` + UserID string `json:"userId"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + CloudflareURL string `json:"cloudflareUrl"` + Visibility Visibility `json:"visibility"` + + User *User `json:"user,omitempty"` +} + +type UserImageRepo struct { + db *sql.DB +} + +func (ui *UserImageRepo) SaveOrUpdateUserImage(ctx context.Context, userID, imageUrl, identifier, pHash string, visibility Visibility) (string, error) { + existingImage, _ := ui.FindByIdentifier(ctx, userID, identifier) + + if existingImage != nil { + _, err := ui.db.ExecContext(ctx, ` + UPDATE "UserImage" SET "cloudflareUrl" = $1, "updatedAt" = $2, visibility = $3 WHERE id = $4`, + imageUrl, time.Now(), visibility, existingImage.ID) + if err != nil { + fmt.Println("error updating image 41") + return "", err + } + return "Image updated successfully", nil + } + + // create UUID here cause negitive DB perf if we make the DB do it + id := uuid.New().String() + _, err := ui.db.ExecContext(ctx, ` + INSERT INTO "UserImage" (id, "userId", "cloudflareUrl", identifier, "createdAt", "updatedAt", visibility) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + id, userID, imageUrl, identifier, time.Now(), time.Now(), visibility) + + if err != nil { + fmt.Println("error saving image 56") + return "", err + } + + _, err = ui.db.ExecContext(ctx, ` + INSERT INTO "ImageHash" (id, "imageId", "perceptualHash") + VALUES ($1, $2, $3)`, + uuid.New().String(), id, pHash) + + if err != nil { + fmt.Printf("Error saving perceptual hash: %v\n", err) + return "", err + } + + + return "Image saved successfully", nil +} + +func (ui *UserImageRepo) FindByIdentifier(ctx context.Context, userId, identifier string) (*UserImage, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + var image UserImage + err := ui.db.QueryRowContext(ctx, ` + SELECT id, "userId", "cloudflareUrl", identifier, "createdAt", "updatedAt", visibility + FROM "UserImage" WHERE "userId" = $1 AND identifier = $2`, userId, identifier). + Scan(&image.ID, &image.UserID, &image.CloudflareURL, &image.Identifier, &image.CreatedAt, &image.UpdatedAt, &image.Visibility) + + if err == sql.ErrNoRows { + fmt.Println("No image found") + return nil, nil + } + + if err != nil { + fmt.Println("Error finding image") + return nil, err + } + + return &image, nil +} \ No newline at end of file diff --git a/internal/util/imageHelper.go b/internal/util/imageHelper.go new file mode 100644 index 0000000..dc26fbe --- /dev/null +++ b/internal/util/imageHelper.go @@ -0,0 +1,24 @@ +package util + +import ( + "bytes" + "image" + _ "image/jpeg" + _ "image/png" + + "github.com/corona10/goimagehash" +) + +func CalculateImageHash(file []byte) (string, error) { + img, _, err := image.Decode(bytes.NewReader(file)) + if err != nil { + return "", err + } + + hash, err := goimagehash.AverageHash(img) + if err != nil { + return "", err + } + + return hash.ToString(), nil +} \ No newline at end of file diff --git a/prisma/migrations/20250209183705_init/migration.sql b/prisma/migrations/20250209183705_init/migration.sql new file mode 100644 index 0000000..a92e2da --- /dev/null +++ b/prisma/migrations/20250209183705_init/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "ImageHash" ( + "id" TEXT NOT NULL, + "imageId" TEXT NOT NULL, + "perceptualHash" TEXT NOT NULL, + + CONSTRAINT "ImageHash_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ImageHash_imageId_key" ON "ImageHash"("imageId"); + +-- AddForeignKey +ALTER TABLE "ImageHash" ADD CONSTRAINT "ImageHash_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "UserImage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e00dfd5..dd99a4b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -31,6 +31,7 @@ model Session { sessionToken String @unique userId String expires DateTime + user User @relation(fields: [userId], references: [id], onDelete: Cascade) } @@ -40,9 +41,9 @@ model User { email String? @unique emailVerified DateTime? image String? + accounts Account[] sessions Session[] - UserImage UserImage[] } @@ -68,4 +69,14 @@ model UserImage { cloudflareUrl String visibility Visibility user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + imageHash ImageHash? +} + +model ImageHash { + id String @id @default(cuid()) + imageId String @unique + perceptualHash String + + userImage UserImage @relation(fields: [imageId], references: [id]) }