diff --git a/Makefile b/Makefile index c4fe7d3b..e5b53982 100644 --- a/Makefile +++ b/Makefile @@ -16,14 +16,17 @@ setup-ci: make generate-sql sync: sync-server sync-web sync-root sync-common - bunx lefthook install || true @echo '----------------------------------------------------------------------------------------------------------' @echo '| Most work is done. now running prisma-generate-sql (which might fail if .env.dev is not set configured)|' @echo '----------------------------------------------------------------------------------------------------------' make generate-sql || true generate-sql: - @cd server; bun run prisma-generate-sql + @cd server; \ + if command -v dotenv && command -v prisma; \ + then dotenv -e .env.dev -- prisma generate --sql; \ + else bunx dotenv -e .env.dev -- bunx prisma generate --sql; \ + fi start: start-all # build -> serve build: build-server build-web @@ -59,7 +62,7 @@ docker-watch: docker compose up --build --watch seed: - cd server; bunx prisma db seed + cd server; if command -v prisma; then prisma db seed; else bunx prisma db seed; fi ## server/.envをDATABASE_URL=postgres://user:password@localhost:5432/databaseにしてから行う dev-db: export DATABASE_URL=$(LOCAL_DB) @@ -79,12 +82,9 @@ dev-db: sleep 1; \ done @echo "PostgreSQL is ready. Running seed..." - @cd server; \ - if command -v prisma; then\ - prisma generate; prisma db push;\ - else \ - bunx prisma generate; bunx prisma db push;\ - fi + @cd server; if command -v prisma; then \ + prisma generate; prisma db push; else \ + bunx prisma generate; bunx prisma db push; fi @make seed @echo "Seeding completed." diff --git a/bun.lockb b/bun.lockb index 760520a5..e069a49d 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/common/zod/schemas.ts b/common/zod/schemas.ts index e5187c7b..432ab50d 100644 --- a/common/zod/schemas.ts +++ b/common/zod/schemas.ts @@ -126,6 +126,7 @@ export const MessageSchema = z.object({ creator: UserIDSchema, createdAt: z.date(), content: ContentSchema, + isPicture: z.boolean(), edited: z.boolean(), }); @@ -172,6 +173,8 @@ export const PersonalizedDMRoomSchema = z.object({ name: NameSchema, thumbnail: z.string(), matchingStatus: MatchingStatusSchema, + unreadMessages: z.number(), + friendId: z.number(), }); export const SharedRoomSchema = z.object({ diff --git a/flake.nix b/flake.nix index d0f8f3a2..dc8ac134 100644 --- a/flake.nix +++ b/flake.nix @@ -29,6 +29,7 @@ openssl lefthook pkgs.prisma + dotenv-cli ] ++ [ rust-pkgs ]; diff --git a/package.json b/package.json index 99611cfe..ff559c83 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "description": "", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "prepare": "lefthook install" }, "keywords": [], "author": "", diff --git a/server/package.json b/server/package.json index c9b150d5..2df018af 100644 --- a/server/package.json +++ b/server/package.json @@ -7,8 +7,7 @@ "test": "echo \"Error: no test specified\" && exit 1", "dev": "bun --watch src/main.ts", "build": "tsc", - "serve": "bun target/main.js", - "prisma-generate-sql": "bunx dotenv -e .env.dev -- prisma generate --sql" + "serve": "bun target/main.js" }, "prisma": { "seed": "bun src/seeds/seed-test.ts" diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 493ae235..9d9ec520 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -42,12 +42,17 @@ model User { interests Interest[] } -// プロフィールの画像。 model Avatar { guid String @id data Bytes } +model Picture { + hash String @id + data Bytes + key String // password +} + model InterestSubject { id Int @id @default(autoincrement()) name String @@ -122,8 +127,6 @@ enum MatchingStatus { REJECTED } -// TODO: lazy load MessageLog s.t. it doesn't need to be loaded on Overview query. -// https://www.prisma.io/docs/orm/prisma-client/queries/select-fields model SharedRoom { id Int @id @default(autoincrement()) thumbnail String // URL to thumbnail picture @@ -134,10 +137,11 @@ model SharedRoom { model Message { id Int @id @default(autoincrement()) - creator Int // refers to UserId + creator Int // refers to UserId createdAt DateTime @default(now()) // @readonly edited Boolean @default(false) content String + isPicture Boolean // iff the message is a picture. if true, then content is a url of picture. read Boolean @default(false) relation Relationship? @relation(fields: [relationId], references: [id], onDelete: Cascade) relationId Int? diff --git a/server/src/database/chat.ts b/server/src/database/chat.ts index 43f6bdd8..f0d21864 100644 --- a/server/src/database/chat.ts +++ b/server/src/database/chat.ts @@ -151,13 +151,14 @@ export async function markAsRead( **/ export async function sendDM( relation: RelationshipID, - content: Omit, + content: Omit, "isPicture">, ): Promise> { try { const message = await prisma.message.create({ data: { // isPicture: false, // todo: bring it back relationId: relation, + isPicture: false, read: false, ...content, }, @@ -167,6 +168,26 @@ export async function sendDM( return Err(e); } } +/** +this doesn't create the image. use uploadPic in database/picture.ts to create the image. +**/ +export async function createImageMessage( + sender: UserID, + relation: RelationshipID, + url: string, +) { + return prisma.message + .create({ + data: { + creator: sender, + relationId: relation, + content: url, + isPicture: true, + }, + }) + .then((val) => Ok(val)) + .catch((err) => Err(err)); +} export async function createSharedRoom( room: InitRoom, diff --git a/server/src/database/picture.ts b/server/src/database/picture.ts index d7409d1a..7c3f45bc 100644 --- a/server/src/database/picture.ts +++ b/server/src/database/picture.ts @@ -2,22 +2,54 @@ import { Err, Ok, type Result } from "common/lib/result"; import type { GUID } from "common/types"; import { prisma } from "./client"; +/** + * @returns URL of the uploaded file. + * @throws on database conn fail. + **/ +export async function uploadPic( + hash: string, + buf: Buffer, + passkey: string, +): Promise { + await prisma.picture.upsert({ + where: { hash }, + create: { hash, data: buf, key: passkey }, + update: { data: buf, key: passkey }, + }); + const url = `/picture/${hash}?key=${passkey}`; + return url; +} + +export async function getPic(hash: string, passkey: string) { + return prisma.picture + .findUnique({ + where: { + hash, + key: passkey, + }, + }) + .then((val) => val?.data); +} + /** * is safe to await. * @returns URL of the file. **/ -export async function set(guid: GUID, buf: Buffer): Promise> { +export async function setProf( + guid: GUID, + buf: Buffer, +): Promise> { return prisma.avatar .upsert({ where: { - guid: guid, + guid, }, create: { guid, data: buf }, update: { data: buf }, }) .then(() => { // ?update=${date} is necessary to let the browsers properly cache the image. - const pictureUrl = `/picture/${guid}?update=${new Date().getTime()}`; + const pictureUrl = `/picture/profile/${guid}?update=${new Date().getTime()}`; return Ok(pictureUrl); }) .catch((err) => { @@ -27,7 +59,7 @@ export async function set(guid: GUID, buf: Buffer): Promise> { } // is await-safe. -export async function get(guid: GUID): Promise> { +export async function getProf(guid: GUID): Promise> { return prisma.avatar .findUnique({ where: { guid }, diff --git a/server/src/functions/chat.ts b/server/src/functions/chat.ts index eb891641..19c1659e 100644 --- a/server/src/functions/chat.ts +++ b/server/src/functions/chat.ts @@ -10,7 +10,7 @@ import type { ShareRoomID, } from "common/types"; import * as db from "../database/chat"; -import { areAllMatched, areMatched, getRelation } from "../database/matches"; +import { areAllMatched, getRelation } from "../database/matches"; import { getUserByID } from "../database/users"; import * as http from "./share/http"; @@ -48,7 +48,7 @@ export async function sendDM( ); // they are now MATCHED - const msg: Omit = { + const msg: Omit, "isPicture"> = { creator: from, createdAt: new Date(), edited: false, @@ -73,8 +73,14 @@ export async function getDM( const friendData = await getUserByID(friend); if (!friendData.ok) return http.notFound("friend not found"); + const unreadCount = db.unreadMessages(user, rel.value.id).then((val) => { + if (val.ok) return val.value; + throw val.error; + }); const personalized: PersonalizedDMRoom & DMRoom = { + unreadMessages: await unreadCount, + friendId: friendData.value.id, name: friendData.value.name, thumbnail: friendData.value.pictureUrl, matchingStatus: diff --git a/server/src/lib/hash.ts b/server/src/lib/hash.ts new file mode 100644 index 00000000..e4a6cc3e --- /dev/null +++ b/server/src/lib/hash.ts @@ -0,0 +1,7 @@ +import crypto from "node:crypto"; + +export function sha256(src: string): string { + const hasher = crypto.createHash("sha256"); + hasher.update(src); + return hasher.digest("hex"); +} diff --git a/server/src/router/picture.ts b/server/src/router/picture.ts index 59199f1b..1b305ddd 100644 --- a/server/src/router/picture.ts +++ b/server/src/router/picture.ts @@ -1,20 +1,75 @@ import bodyParser from "body-parser"; +import { safeParseInt } from "common/lib/result/safeParseInt"; import express from "express"; +import * as chat from "../database/chat"; +import * as relation from "../database/matches"; import * as storage from "../database/picture"; +import { safeGetUserId } from "../firebase/auth/db"; import { safeGetGUID } from "../firebase/auth/lib"; import { compressImage } from "../functions/img/compress"; +import * as hashing from "../lib/hash"; -// TODO: truncate file at frontend s.t. even the largest file won't trigger the limit const parseLargeBuffer = bodyParser.raw({ type: "image/png", - // TODO: block large files (larger than 1mb? idk) limit: "5mb", }); const router = express.Router(); -router.get("/:guid", async (req, res) => { +/* General Pictures in chat */ + +router.post("/to/:userId", parseLargeBuffer, async (req, res) => { + if (!Buffer.isBuffer(req.body)) return res.status(400).send("not buffer"); + const buf = req.body; + + const sender = await safeGetUserId(req); + if (!sender.ok) return res.status(401).end(); + const recv = safeParseInt(req.params.userId); + if (!recv.ok) return res.status(400).end(); + + const rel = await relation.getRelation(sender.value, recv.value); + if (!rel.ok) return res.status(401).send(); + if (rel.value.status !== "MATCHED") return res.status(401).send(); + + const hash = hashing.sha256(buf.toString("base64")); + const passkey = hashing.sha256(crypto.randomUUID()); + + return storage + .uploadPic(hash, buf, passkey) + .then(async (url) => { + await chat.createImageMessage(sender.value, rel.value.id, url); + res.status(201).send(url).end(); + }) + .catch((err) => { + console.log(err); + res.status(500).send("Failed to upload image to database").end(); + }); +}); + +router.get("/:id", async (req, res) => { + const hash = req.params.id; + const key = req.query.key; + if (!key) return res.status(400).send("key is required"); + + return storage + .getPic(hash, String(key)) + .then((buf) => { + if (buf) { + res.status(200).send(buf).end(); + } else { + res.status(404).send("not found").end(); + } + }) + .catch((err) => { + console.error(err); + res.status(500).send("Failed to get image from database").end(); + }); +}); + +/* Profile Pictures */ + +router.get("/profile/:guid", async (req, res) => { const guid = req.params.guid; - const result = await storage.get(guid); + const result = await storage.getProf(guid); switch (result.ok) { case true: return res.send(new Buffer(result.value)); @@ -23,16 +78,16 @@ router.get("/:guid", async (req, res) => { } }); -router.post("/", parseLargeBuffer, async (req, res) => { +router.post("/profile", parseLargeBuffer, async (req, res) => { const guid = await safeGetGUID(req); if (!guid.ok) return res.status(401).send(); - if (!Buffer.isBuffer(req.body)) return res.status(404).send("not buffer"); + if (!Buffer.isBuffer(req.body)) return res.status(400).send("not buffer"); const buf = await compressImage(req.body); if (!buf.ok) return res.status(500).send("failed to compress image"); - const url = await storage.set(guid.value, buf.value); + const url = await storage.setProf(guid.value, buf.value); if (!url.ok) return res.status(500).send("failed to upload image"); return res.status(201).type("text/plain").send(url.value); diff --git a/web/api/image.ts b/web/api/image.ts index d3ccfe3b..2f5cb478 100644 --- a/web/api/image.ts +++ b/web/api/image.ts @@ -1 +1,14 @@ -export { uploadImage } from "./internal/fetch-func"; +import type { UserID } from "common/types"; +import * as endpoints from "./internal/endpoints"; +import { uploadImage as uploader } from "./internal/fetch-func"; +export { MAX_IMAGE_SIZE } from "./internal/fetch-func"; + +export async function uploadAvatar(f: File) { + return await uploader(endpoints.profilePicture, f); +} + +/** @throws if failed to send image. **/ +export async function sendImageTo(u: UserID, f: File) { + console.log("sendImageTo"); + await uploader(endpoints.sendPictureTo(u), f); +} diff --git a/web/api/internal/endpoints.ts b/web/api/internal/endpoints.ts index a170eec1..d10d611e 100644 --- a/web/api/internal/endpoints.ts +++ b/web/api/internal/endpoints.ts @@ -365,15 +365,22 @@ export const roomInvite = (roomId: ShareRoomID) => export const message = (messageId: MessageID) => `${API_ENDPOINT}/chat/messages/id/${messageId}`; +/** + * POST: send picture. + */ +export const sendPictureTo = (friendId: UserID) => + `${API_ENDPOINT}/picture/to/${friendId}`; /** * GET: get profile picture of URL (this is usually hard-encoded in pictureURL so this variable is barely used) */ +export const profilePictureOf = (guid: GUID) => + `${API_ENDPOINT}/picture/profile/${guid}`; export const pictureOf = (guid: GUID) => `${API_ENDPOINT}/picture/${guid}`; /** * POST: update my profile picture. */ -export const picture = `${API_ENDPOINT}/picture`; +export const profilePicture = `${API_ENDPOINT}/picture/profile`; export default { user, @@ -402,6 +409,6 @@ export default { message, coursesMine, coursesMineOverlaps, - pictureOf, - picture, + profilePictureOf, + profilePicture, }; diff --git a/web/api/internal/fetch-func.ts b/web/api/internal/fetch-func.ts index 46881c75..1c4486b8 100644 --- a/web/api/internal/fetch-func.ts +++ b/web/api/internal/fetch-func.ts @@ -1,4 +1,5 @@ import { Err, Ok, type Result } from "common/lib/result"; +import { getIdToken } from "~/firebase/auth/lib"; export async function safeFetch( path: string, @@ -15,18 +16,15 @@ export async function safeFetch( } } -import { getIdToken } from "~/firebase/auth/lib"; -import endpoints from "./endpoints"; - type URL = string; export const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB -export async function uploadImage(file: File): Promise { +export async function uploadImage(path: string, file: File): Promise { if (file.size >= MAX_IMAGE_SIZE) { throw new Error("画像のアップロードに失敗しました: 画像が大きすぎます"); } - const res = await fetch(`${endpoints.picture}?token=${await getIdToken()}`, { + const res = await fetch(`${path}?token=${await getIdToken()}`, { method: "POST", headers: { "Content-Type": "image/png", diff --git a/web/bun.lockb b/web/bun.lockb index efb49f51..c2057c23 100755 Binary files a/web/bun.lockb and b/web/bun.lockb differ diff --git a/web/components/chat/MessageInput.tsx b/web/components/chat/MessageInput.tsx index 7d8119a6..c6656418 100644 --- a/web/components/chat/MessageInput.tsx +++ b/web/components/chat/MessageInput.tsx @@ -1,18 +1,24 @@ -import type { SendMessage, UserID } from "common/types"; +import ImageIcon from "@mui/icons-material/Image"; +import { sendImageTo } from "../../api/image"; + +import type { DMOverview, SendMessage, UserID } from "common/types"; import { parseContent } from "common/zod/methods"; import { useEffect, useState } from "react"; import { MdSend } from "react-icons/md"; type Props = { send: (to: UserID, m: SendMessage) => void; - friendId: UserID; + reload: () => void; + room: DMOverview; }; const crossRoomMessageState = new Map(); -export function MessageInput({ send, friendId }: Props) { +export function MessageInput({ reload, send, room }: Props) { + const friendId = room.friendId; const [message, _setMessage] = useState(""); const [error, setError] = useState(null); + const isMatched = room.matchingStatus === "matched"; function setMessage(m: string) { _setMessage(m); @@ -60,6 +66,25 @@ export function MessageInput({ send, friendId }: Props) {
+ {isMatched && ( + + )}