@ -0,0 +1,3 @@ |
|||
POSTGRES_USER=root |
|||
POSTGRES_PASSWORD=root |
|||
POSTGRES_DB=info |
@ -0,0 +1,7 @@ |
|||
frontend/node_modules |
|||
go.sum |
|||
social-network |
|||
users.db |
|||
fiber |
|||
frontend/.next/ |
|||
/out/ |
@ -0,0 +1,7 @@ |
|||
FROM nginx:latest |
|||
|
|||
WORKDIR /etc/nginx/ |
|||
|
|||
COPY nginx.conf . |
|||
|
|||
EXPOSE 80 |
@ -0,0 +1,50 @@ |
|||
# CopyBook |
|||
|
|||
A messageboard type social website |
|||
|
|||
## Backend |
|||
Fully coded in golang utilizing the following technologies: |
|||
- https://github.com/gofiber/fiber |
|||
- https://gorm.io/ |
|||
|
|||
The backend consists of multiple modules: |
|||
- db.go: database modules setup with gorm |
|||
- auth.go: authorization module with jwt tokens setup |
|||
- server.go: main buisness logic functionality |
|||
|
|||
|
|||
|
|||
## Frontend |
|||
Typescript + nextjs + tailwindcss |
|||
|
|||
the frontend uses nextjs for server side rendering. |
|||
|
|||
there are multiple pages each responsible for its own functionality: |
|||
- index.tsx: mounted on `/` is the homepage for the messageboard. |
|||
- login.tsx: mounted on `/login` the login page |
|||
- logout.tsx: mounted on `/logout` for logging out and removing the jwt token. |
|||
- profile.tsx: mounted on `/profile` is for the user profile info. |
|||
- signup.tsx: mounted on `/signup` is for registering new users. |
|||
|
|||
|
|||
|
|||
|
|||
## Devops |
|||
Fully dockerized and ready for deployment on any environment |
|||
|
|||
backend setup consists of 2 containers one that builds the golang binary for and another that runs the server |
|||
|
|||
the database for the backend is running a postgresql server with persistent storage enabled |
|||
|
|||
frontend setup has 3 containers: |
|||
`deps`: pulls the dependecies required for compiling the typescript files. |
|||
`builder`: compiles the typescript files. |
|||
`runner`: runs the nextjs server. |
|||
|
|||
all of the previous services are running on `internalnet` virtual network and then afterwards its served with a nginx reverse proxy for traffic management and redundancy purposes |
|||
|
|||
## TODO |
|||
- better db management |
|||
- more documentation |
|||
- user personalization |
|||
- more ui changes |
@ -0,0 +1,13 @@ |
|||
FROM golang AS build |
|||
# RUN apk --no-cache add gcc g++ make git |
|||
WORKDIR /go/src/app |
|||
COPY . . |
|||
RUN go mod tidy |
|||
# RUN GOOS=linux go build -ldflags="-s -w" -o ./bin/web-app |
|||
RUN GOOS=linux go build -o ./bin/web-app |
|||
|
|||
FROM ubuntu |
|||
WORKDIR /usr/bin |
|||
COPY --from=build /go/src/app/bin /go/bin |
|||
EXPOSE 3000 |
|||
ENTRYPOINT /go/bin/web-app |
@ -0,0 +1,3 @@ |
|||
# Backend API for the social network |
|||
|
|||
rest api utlizing golang and the gofiber framework |
@ -0,0 +1,35 @@ |
|||
package main |
|||
|
|||
import ( |
|||
"time" |
|||
|
|||
"github.com/golang-jwt/jwt/v4" |
|||
) |
|||
|
|||
var jwtKey = []byte("ShXYLRYfFOw+upPD") |
|||
|
|||
type Claim struct { |
|||
Username string `json:"username"` |
|||
jwt.RegisteredClaims |
|||
} |
|||
|
|||
func Sign(username string) (string, error) { |
|||
expirationTime := jwt.NewNumericDate(time.Now().Add(5 * time.Hour)) |
|||
|
|||
claims := &Claim{ |
|||
Username: username, |
|||
RegisteredClaims: jwt.RegisteredClaims{ |
|||
ExpiresAt: expirationTime, |
|||
}, |
|||
} |
|||
|
|||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) |
|||
|
|||
tokenString, err := token.SignedString(jwtKey) |
|||
if err != nil { |
|||
return "", err |
|||
} |
|||
|
|||
return tokenString, err |
|||
|
|||
} |
@ -0,0 +1,75 @@ |
|||
package main |
|||
|
|||
import ( |
|||
"gorm.io/driver/postgres" |
|||
"gorm.io/gorm" |
|||
|
|||
"golang.org/x/crypto/bcrypt" |
|||
) |
|||
|
|||
type User struct { |
|||
ID uint `gorm:"primaryKey"` |
|||
Username string |
|||
Password string |
|||
Email string |
|||
Active bool |
|||
} |
|||
|
|||
type APIUser struct { |
|||
ID uint |
|||
Username string |
|||
Email string |
|||
} |
|||
|
|||
type Comment struct { |
|||
UID uint `gorm:"primaryKey"` |
|||
PostId uint |
|||
Commenter string |
|||
Text string |
|||
} |
|||
|
|||
type APIComment struct { |
|||
PostId uint `json:"post_id"` |
|||
Text string `json:"content"` |
|||
} |
|||
|
|||
type Post struct { |
|||
ID uint `gorm:"primaryKey"` |
|||
Content string |
|||
Username string |
|||
Comments string |
|||
} |
|||
|
|||
func initDB() *gorm.DB { |
|||
dburl := "host=db user=root password=root dbname=info port=5432 sslmode=disable TimeZone=Asia/Shanghai" |
|||
db, err := gorm.Open(postgres.Open(dburl), &gorm.Config{}) |
|||
|
|||
if err != nil { |
|||
panic("failed to open db") |
|||
} |
|||
|
|||
db.AutoMigrate(&User{}) |
|||
db.AutoMigrate(&Post{}) |
|||
return db |
|||
} |
|||
|
|||
func validateUser(u User) bool { |
|||
if len(u.Username) < 3 { |
|||
return false |
|||
} |
|||
if len(u.Password) < 3 { |
|||
return false |
|||
} |
|||
|
|||
return true |
|||
} |
|||
|
|||
func HashPassword(password string) (string, error) { |
|||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14) |
|||
return string(bytes), err |
|||
} |
|||
|
|||
func CheckPasswordHash(password, hash string) bool { |
|||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) |
|||
return err == nil |
|||
} |
@ -0,0 +1,26 @@ |
|||
module social-network |
|||
|
|||
go 1.13 |
|||
|
|||
require ( |
|||
github.com/charmbracelet/bubbles v0.11.0 // indirect |
|||
github.com/fsnotify/fsnotify v1.5.4 // indirect |
|||
github.com/gofiber/cli v0.0.9 // indirect |
|||
github.com/gofiber/fiber/v2 v2.34.0 |
|||
github.com/gofiber/jwt/v3 v3.2.12 // indirect |
|||
github.com/golang-jwt/jwt/v4 v4.4.1 // indirect |
|||
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect |
|||
github.com/jackc/pgx/v5 v5.5.5 // indirect |
|||
github.com/jinzhu/now v1.1.5 // indirect |
|||
github.com/klauspost/compress v1.15.6 // indirect |
|||
github.com/mattn/go-sqlite3 v1.14.13 // indirect |
|||
github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect |
|||
github.com/muesli/cancelreader v0.2.2 // indirect |
|||
github.com/muesli/termenv v0.12.0 // indirect |
|||
github.com/spf13/cobra v1.5.0 // indirect |
|||
golang.org/x/crypto v0.22.0 // indirect |
|||
golang.org/x/sync v0.7.0 // indirect |
|||
gorm.io/driver/postgres v1.5.7 // indirect |
|||
gorm.io/driver/sqlite v1.3.4 // indirect |
|||
gorm.io/gorm v1.25.9 // indirect |
|||
) |
@ -0,0 +1,232 @@ |
|||
package main |
|||
|
|||
import ( |
|||
"bytes" |
|||
"fmt" |
|||
"log" |
|||
|
|||
"encoding/base64" |
|||
"encoding/gob" |
|||
"encoding/json" |
|||
|
|||
"github.com/gofiber/fiber/v2" |
|||
"github.com/gofiber/fiber/v2/middleware/cors" |
|||
jwtware "github.com/gofiber/jwt/v3" |
|||
"github.com/golang-jwt/jwt/v4" |
|||
) |
|||
|
|||
type Creds struct { |
|||
Username string `json:"username" xml:"username" form:"username"` |
|||
Pass string `json:"pass" xml:"pass" form:"pass"` |
|||
} |
|||
|
|||
type Response struct { |
|||
Status string `json:"status"` |
|||
Text string `json:"text"` |
|||
} |
|||
|
|||
type JsonComment struct { |
|||
Array []Comment |
|||
} |
|||
|
|||
// go binary encoder
|
|||
func ToGOB64(m JsonComment) string { |
|||
b := bytes.Buffer{} |
|||
e := gob.NewEncoder(&b) |
|||
err := e.Encode(m) |
|||
if err != nil { |
|||
fmt.Println(`failed gob Encode`, err) |
|||
} |
|||
return base64.StdEncoding.EncodeToString(b.Bytes()) |
|||
} |
|||
|
|||
// go binary decoder
|
|||
func FromGOB64(str string) JsonComment { |
|||
m := JsonComment{} |
|||
by, err := base64.StdEncoding.DecodeString(str) |
|||
if err != nil { |
|||
fmt.Println(`failed base64 Decode`, err) |
|||
} |
|||
b := bytes.Buffer{} |
|||
b.Write(by) |
|||
d := gob.NewDecoder(&b) |
|||
err = d.Decode(&m) |
|||
if err != nil { |
|||
fmt.Println(`failed gob Decode`, err) |
|||
} |
|||
return m |
|||
} |
|||
|
|||
func malformedToken(c *fiber.Ctx, w error) error { |
|||
res := Response{Status: "bad", Text: "Malformed token"} |
|||
return c.JSON(res) |
|||
} |
|||
|
|||
func main() { |
|||
app := fiber.New() |
|||
db := initDB() |
|||
bad := Response{Status: "bad", Text: "Unauthorized"} |
|||
errResponse := Response{Status: "bad", Text: "Woops something went wrong ¯\\_(ツ)_/¯"} |
|||
app.Use(cors.New(cors.Config{ |
|||
AllowOrigins: "*", |
|||
AllowHeaders: "Origin, Content-Type, Accept, Authorization", |
|||
})) |
|||
app.Post("/login", func(c *fiber.Ctx) error { |
|||
p := new(Creds) |
|||
if err := c.BodyParser(p); err != nil { |
|||
return err |
|||
} |
|||
|
|||
var res User |
|||
if err := db.First(&res, "Username = ?", p.Username).Error; err != nil { |
|||
return c.JSON(bad) |
|||
} |
|||
|
|||
if !CheckPasswordHash(p.Pass, res.Password) { |
|||
return c.JSON(bad) |
|||
} |
|||
|
|||
token, _ := Sign(p.Username) |
|||
response := Response{Status: "ok", Text: token} |
|||
return c.JSON(response) |
|||
}) |
|||
|
|||
app.Post("/signup", func(c *fiber.Ctx) error { |
|||
details := new(User) |
|||
|
|||
if err := c.BodyParser(details); err != nil { |
|||
return err |
|||
} |
|||
|
|||
if !validateUser(*details) { |
|||
return c.JSON(errResponse) |
|||
} |
|||
details.Active = true |
|||
|
|||
var query User |
|||
if err := db.First(&query, "Username = ?", details.Username).Error; err != nil && query.Username != "" { |
|||
return c.JSON(errResponse) |
|||
} |
|||
|
|||
hashedPass, err := HashPassword(details.Password) |
|||
if err != nil { |
|||
return c.JSON(errResponse) |
|||
} |
|||
|
|||
details.Password = hashedPass |
|||
|
|||
db.Create(details) |
|||
newUser := Response{Status: "ok", Text: "user created successfully"} |
|||
return c.JSON(newUser) |
|||
}) |
|||
|
|||
app.Use(jwtware.New(jwtware.Config{ |
|||
SigningKey: []byte("ShXYLRYfFOw+upPD"), |
|||
ErrorHandler: malformedToken, |
|||
})) |
|||
|
|||
app.Get("/posts", func(c *fiber.Ctx) error { |
|||
var query []Post |
|||
c.Set(fiber.HeaderContentType, fiber.MIMEApplicationJSONCharsetUTF8) |
|||
|
|||
db.Find(&query) |
|||
|
|||
res, err := json.Marshal(query) |
|||
|
|||
if err != nil { |
|||
return c.JSON(errResponse) |
|||
} |
|||
|
|||
return c.SendString(string(res)) |
|||
}) |
|||
|
|||
app.Get("/posts/:uid", func(c *fiber.Ctx) error { |
|||
var post Post |
|||
|
|||
db.Select("content").Find(&post, "ID = ?", c.Params("uid")) |
|||
res := Response{Status: "ok", Text: post.Content} |
|||
return c.JSON(res) |
|||
}) |
|||
|
|||
app.Get("/profile/:uid", func(c *fiber.Ctx) error { |
|||
var user APIUser |
|||
if err := db.Model(&User{}).Find(&user, "Username = ?", c.Params("uid")).Error; err != nil { |
|||
return c.JSON(errResponse) |
|||
} |
|||
return c.JSON(user) |
|||
}) |
|||
|
|||
app.Get("/profile", func(c *fiber.Ctx) error { |
|||
var user APIUser |
|||
token := c.Locals("user").(*jwt.Token) |
|||
claims := token.Claims.(jwt.MapClaims) |
|||
name := claims["username"].(string) |
|||
|
|||
if err := db.Model(&User{}).Find(&user, "Username = ?", name).Error; err != nil { |
|||
return c.JSON(errResponse) |
|||
} |
|||
return c.JSON(user) |
|||
}) |
|||
|
|||
app.Post("/create", func(c *fiber.Ctx) error { |
|||
info := new(Post) |
|||
|
|||
if err := c.BodyParser(info); err != nil { |
|||
return c.JSON(errResponse) |
|||
} |
|||
|
|||
user := c.Locals("user").(*jwt.Token) |
|||
claims := user.Claims.(jwt.MapClaims) |
|||
name := claims["username"].(string) |
|||
|
|||
//figure out how to store user id's instead of usernames
|
|||
/* |
|||
var query User |
|||
if err := db.First(&query, "Username = ?", name).Error; err != nil && query.Username != "" { |
|||
return c.JSON(errResponse) |
|||
} |
|||
info.Uuid = query.ID |
|||
*/ |
|||
info.Username = name |
|||
db.Create(info) |
|||
newPost := Response{Status: "ok", Text: "post created successfully"} |
|||
return c.JSON(newPost) |
|||
}) |
|||
|
|||
app.Post("/comment", func(c *fiber.Ctx) error { |
|||
var comment Comment |
|||
apiComment := new(APIComment) |
|||
if err := c.BodyParser(apiComment); err != nil { |
|||
return c.JSON(errResponse) |
|||
} |
|||
|
|||
user := c.Locals("user").(*jwt.Token) |
|||
claims := user.Claims.(jwt.MapClaims) |
|||
name := claims["username"].(string) |
|||
|
|||
comment.PostId = apiComment.PostId |
|||
comment.Text = apiComment.Text |
|||
comment.Commenter = name |
|||
|
|||
var post Post |
|||
|
|||
db.Select("comments").Find(&post, "ID = ?", comment.PostId) |
|||
dbComments := FromGOB64(post.Comments) |
|||
|
|||
comments := JsonComment{Array: append([]Comment{comment}, dbComments.Array...)} |
|||
store := ToGOB64(comments) |
|||
db.Model(&Post{}).Where("ID = ?", comment.PostId).Update("comments", store) |
|||
newComment := Response{Status: "ok", Text: "Comment created successfully"} |
|||
return c.JSON(newComment) |
|||
}) |
|||
|
|||
app.Get("/comments/:uid", func(c *fiber.Ctx) error { |
|||
var post Post |
|||
|
|||
db.Select("comments").Find(&post, "ID = ?", c.Params("uid")) |
|||
comments := FromGOB64(post.Comments) |
|||
return c.JSON(comments) |
|||
}) |
|||
|
|||
log.Fatal(app.Listen(":3000")) |
|||
} |
@ -0,0 +1,58 @@ |
|||
services: |
|||
|
|||
nginx: |
|||
build: . |
|||
container_name: nginx |
|||
restart: "always" |
|||
privileged: true |
|||
links: |
|||
- "go-api:api" |
|||
- "frontend:frontend" |
|||
ports: |
|||
- 80:80 |
|||
networks: |
|||
- internalnet |
|||
|
|||
go-api: |
|||
build: ./backend |
|||
restart: "always" |
|||
container_name: api |
|||
environment: |
|||
- DB_USER=${POSTGRES_USER} |
|||
- DB_PASSWORD=${POSTGRES_PASSWORD} |
|||
- DB_NAME=${POSTGRES_DB} |
|||
- DB_HOST=db |
|||
- DB_PORT=5432 |
|||
ports: |
|||
- 3000:3000 |
|||
depends_on: |
|||
- db |
|||
networks: |
|||
- internalnet |
|||
|
|||
|
|||
frontend: |
|||
build: ./frontend |
|||
restart: "always" |
|||
container_name: frontend |
|||
ports: |
|||
- 3001:3000 |
|||
networks: |
|||
- internalnet |
|||
db: |
|||
image: postgres:latest |
|||
environment: |
|||
- POSTGRES_USER=${POSTGRES_USER} |
|||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD} |
|||
- POSTGRES_DB=${POSTGRES_DB} |
|||
volumes: |
|||
- postgres_data:/var/lib/postgresql/data |
|||
networks: |
|||
- internalnet |
|||
|
|||
networks: |
|||
internalnet: |
|||
driver: bridge |
|||
|
|||
volumes: |
|||
postgres_data: |
@ -0,0 +1,7 @@ |
|||
Dockerfile |
|||
.dockerignore |
|||
node_modules |
|||
npm-debug.log |
|||
README.md |
|||
.next |
|||
.git |
@ -0,0 +1 @@ |
|||
NEXT_PUBLIC_URL=127.0.0.1:3000 |
@ -0,0 +1,60 @@ |
|||
# Install dependencies only when needed |
|||
FROM node:16-alpine 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* ./ |
|||
RUN \ |
|||
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ |
|||
elif [ -f package-lock.json ]; then npm ci; \ |
|||
elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i; \ |
|||
else echo "Lockfile not found." && exit 1; \ |
|||
fi |
|||
|
|||
|
|||
# Rebuild the source code only when needed |
|||
FROM node:16-alpine 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 yarn build |
|||
|
|||
# If using npm comment out above and use below instead |
|||
RUN npm run build |
|||
|
|||
# Production image, copy all the files and run next |
|||
FROM node:16-alpine 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 |
|||
|
|||
# You only need to copy next.config.js if you are NOT using the default configuration |
|||
# COPY --from=builder /app/next.config.js ./ |
|||
COPY --from=builder /app/public ./public |
|||
COPY --from=builder /app/package.json ./package.json |
|||
|
|||
# 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 |
|||
|
|||
CMD ["node", "server.js"] |
@ -0,0 +1,3 @@ |
|||
# Frontend for the social network |
|||
|
|||
the frontend uses typscript + nextjs |
@ -0,0 +1,22 @@ |
|||
import Link from 'next/link' |
|||
|
|||
export default function CommentBox({ |
|||
content, |
|||
user, |
|||
id |
|||
}: { |
|||
content: string |
|||
user: string |
|||
id: number |
|||
}) { |
|||
return ( |
|||
<div className="card w-96 card-side text-white hover:bg-base-300"> |
|||
<div className="card-body"> |
|||
<Link href={`/profile/${user}`}> |
|||
<h2 className="hover:text-primary card-title">{user}</h2> |
|||
</Link> |
|||
<p className="text-xl">{content}</p> |
|||
</div> |
|||
</div> |
|||
) |
|||
} |
@ -0,0 +1,27 @@ |
|||
import Link from 'next/link' |
|||
|
|||
export default function PostBox({ |
|||
content, |
|||
user, |
|||
id |
|||
}: { |
|||
content: string |
|||
user: string |
|||
id: number |
|||
}) { |
|||
return ( |
|||
<Link href={`/post/${id}`}> |
|||
<div className="card w-96 card-side text-white hover:bg-base-300"> |
|||
<div className="card-body"> |
|||
<Link href={`/profile/${user}`}> |
|||
<h2 className="hover:text-primary card-title">{user}</h2> |
|||
</Link> |
|||
<p className="text-xl">{content}</p> |
|||
<div className="card-actions justify-end"> |
|||
{/*<button className="btn btn-primary">Like</button>*/ } |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</Link> |
|||
) |
|||
} |
@ -0,0 +1,32 @@ |
|||
import Link from 'next/link' |
|||
|
|||
export default function Dashboard() { |
|||
return ( |
|||
<div> |
|||
<ul className="menu bg-base-300 rounded-box fixed min-h-full py-6 space-y-9"> |
|||
<li> |
|||
<Link href="/"> |
|||
<a> |
|||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg> |
|||
</a> |
|||
</Link> |
|||
</li> |
|||
<li> |
|||
<Link href="/profile"> |
|||
<a> |
|||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> |
|||
</a> |
|||
</Link> |
|||
</li> |
|||
<li> |
|||
<a> |
|||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg> |
|||
</a> |
|||
</li> |
|||
</ul> |
|||
<header className='grid place-items-center pt-5 pb-20'> |
|||
<h1>Copybook</h1> |
|||
</header> |
|||
</div> |
|||
); |
|||
} |
@ -0,0 +1,5 @@ |
|||
/// <reference types="next" />
|
|||
/// <reference types="next/image-types/global" />
|
|||
|
|||
// NOTE: This file should not be edited
|
|||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
@ -0,0 +1,15 @@ |
|||
/** @type {import('next').NextConfig} */ |
|||
|
|||
const nextConfig = { |
|||
reactStrictMode: true, |
|||
nx: { |
|||
svgr: false, |
|||
}, |
|||
// fix for running the docker container
|
|||
output: 'standalone', |
|||
experimental: { |
|||
outputStandalone: true, |
|||
} |
|||
} |
|||
|
|||
module.exports = nextConfig |
@ -0,0 +1,27 @@ |
|||
{ |
|||
"name": "social-app", |
|||
"version": "0.1.0", |
|||
"private": true, |
|||
"scripts": { |
|||
"dev": "next dev", |
|||
"build": "next build", |
|||
"start": "next start", |
|||
"lint": "next lint" |
|||
}, |
|||
"dependencies": { |
|||
"daisyui": "^2.19.0", |
|||
"next": "12.1.6", |
|||
"react": "18.1.0", |
|||
"react-dom": "18.1.0", |
|||
"react-router-dom": "^6.3.0" |
|||
}, |
|||
"devDependencies": { |
|||
"@types/node": "^17.0.40", |
|||
"@types/react": "^18.0.11", |
|||
"autoprefixer": "^10.4.7", |
|||
"eslint": "8.17.0", |
|||
"eslint-config-next": "12.1.6", |
|||
"postcss": "^8.4.14", |
|||
"tailwindcss": "^3.0.24" |
|||
} |
|||
} |
@ -0,0 +1,6 @@ |
|||
import '../styles/globals.css' |
|||
import { AppProps } from 'next/app' |
|||
|
|||
export default function MyApp({ Component, pageProps }: AppProps) { |
|||
return <Component {...pageProps} /> |
|||
} |
@ -0,0 +1,60 @@ |
|||
import PostBox from '../components/post'; |
|||
import Dashboard from '../components/ui'; |
|||
import React, { useState, useEffect } from 'react'; |
|||
import { useRouter } from 'next/router'; |
|||
let url = process.env.NEXT_PUBLIC_URL; |
|||
export default function Home(props) { |
|||
const router = useRouter(); |
|||
let [posts, setPosts] = useState([]); |
|||
useEffect(() => { |
|||
let token = localStorage.getItem("Token"); |
|||
let ps = async () => { |
|||
setPosts(await fetch(`http://${url}/posts`, { headers: { "Authorization": `Bearer ${token}` }, mode: "cors" }).then(r => r.json())); |
|||
} |
|||
ps().catch(console.error); |
|||
setInterval(refreshPosts, 20000) |
|||
}, []); |
|||
|
|||
if (posts["status"] == "bad") { |
|||
localStorage.removeItem("Token"); |
|||
router.push("/login"); |
|||
} |
|||
|
|||
let content = []; |
|||
if (posts.length != 0 && posts["status"] == undefined) { |
|||
content = Object.assign([], posts.map(post => { |
|||
return [post.Content, post.ID, post.Username] |
|||
})); |
|||
content.reverse() |
|||
} |
|||
|
|||
let refreshPosts = async() => { |
|||
let token = localStorage.getItem("Token"); |
|||
let ps = async () => { |
|||
setPosts(await fetch(`http://${url}/posts`, { headers: { "Authorization": `Bearer ${token}` }, mode: "cors" }).then(r => r.json())); |
|||
} |
|||
ps().catch(console.error); |
|||
} |
|||
|
|||
let newPost = async () => { |
|||
let token = localStorage.getItem("Token"); |
|||
let y = document.getElementById("postContent") as HTMLInputElement; |
|||
await fetch(`http://${url}/create`, { method: "POST", headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" }, mode: "cors", body: JSON.stringify({ Title: "UI", Content: y.value }) }); |
|||
y.value = ""; |
|||
await refreshPosts(); |
|||
} |
|||
|
|||
return ( |
|||
<div> |
|||
<Dashboard /> |
|||
<main className='flex flex-col place-items-center space-y-3'> |
|||
<textarea id="postContent" className="textarea textarea-secondary text-xl" placeholder="What's on your mind ?"></textarea> |
|||
<button className="btn btn-outline btn-secondary w-20" onClick={newPost}>Post</button> |
|||
{content.length != 0 ? content.map(post => { |
|||
return <PostBox id={post[1]} content={post[0]} key={post[1]} user={post[2]}/> |
|||
}) : ""} |
|||
</main> |
|||
</div> |
|||
|
|||
) |
|||
} |
@ -0,0 +1,50 @@ |
|||
import { useRouter } from 'next/router'; |
|||
import { useEffect } from 'react'; |
|||
import Link from 'next/link' |
|||
let url = process.env.NEXT_PUBLIC_URL |
|||
|
|||
export default function Login() { |
|||
const router = useRouter(); |
|||
|
|||
useEffect(() => { |
|||
let stuff = localStorage.getItem("Token"); |
|||
if (stuff != null) { |
|||
router.push("/") |
|||
} |
|||
}, []); |
|||
|
|||
let grab_token = async (event) => { |
|||
event.preventDefault(); |
|||
let username = (document.getElementById("Username") as HTMLInputElement).value; |
|||
let password = (document.getElementById("Password") as HTMLInputElement).value; |
|||
|
|||
let token = await fetch(`http://${url}/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, mode: "cors", body: JSON.stringify({ "username": username, "pass": password }) }).then(r => r.json()); |
|||
if (token["status"] == "ok") { |
|||
await localStorage.setItem("Token", token["text"]); |
|||
router.push('/') |
|||
} |
|||
else { |
|||
alert("bad username/password"); |
|||
} |
|||
} |
|||
|
|||
|
|||
|
|||
return ( |
|||
<div> |
|||
<header className='grid place-items-center pt-5 pb-20 text-white text-xl'> |
|||
<h1>Copybook</h1> |
|||
</header> |
|||
|
|||
<form className="grid place-items-center space-y-5" action='#' onSubmit={grab_token}> |
|||
<h1 className="text-xl pb-5">Login</h1> |
|||
<input id="Username" type="text" placeholder="Username" className="input input-bordered input-accent w-full max-w-xs text-xl" /> |
|||
<input id="Password" type="password" placeholder="Password" className="input input-bordered input-primary w-full max-w-xs text-xl" /> |
|||
<button className="btn btn-outline btn-accent w-40">Login</button> |
|||
<Link href="/signup"> |
|||
<a className="link link-accent">Signup ?</a> |
|||
</Link> |
|||
</form> |
|||
</div> |
|||
) |
|||
} |
@ -0,0 +1,15 @@ |
|||
import { useRouter } from 'next/router'; |
|||
import React, { useEffect } from 'react'; |
|||
|
|||
export default function Logout() { |
|||
const router = useRouter(); |
|||
useEffect(() => { |
|||
localStorage.removeItem("Token"); |
|||
router.push("/login"); |
|||
}) |
|||
return ( |
|||
<div> |
|||
<h1>Logging you out ...</h1> |
|||
</div> |
|||
); |
|||
} |
@ -0,0 +1,56 @@ |
|||
import { useState, useEffect } from 'react'; |
|||
import { useRouter } from 'next/router'; |
|||
import Dashboard from '../../components/ui'; |
|||
import CommentBox from '../../components/comment'; |
|||
|
|||
export default function Profile() { |
|||
const router = useRouter(); |
|||
|
|||
let pid = router.query.pid as string; |
|||
let [post, setPost] = useState({text: ""}); |
|||
let [comments, setComments] = useState([]); |
|||
useEffect(() => { |
|||
if (!router.isReady) return; |
|||
let token = localStorage.getItem("Token"); |
|||
let ps = async () => { |
|||
pid = router.query.pid as string; |
|||
setPost(await fetch(`http://127.0.0.1:3000/posts/${pid}`, { headers: { "Authorization": `Bearer ${token}` }, mode: "cors" }).then(r => r.json())); |
|||
setComments(await fetch(`http://127.0.0.1:3000/comments/${pid}`, { headers: { "Authorization": `Bearer ${token}` }, mode: "cors" }).then(r => r.json())); |
|||
} |
|||
ps().catch(console.error); |
|||
}, [router.isReady]); |
|||
|
|||
let refreshComments = () => { |
|||
let token = localStorage.getItem("Token"); |
|||
let ps = async () => { |
|||
setComments(await fetch(`http://127.0.0.1:3000/comments/${pid}`, { headers: { "Authorization": `Bearer ${token}` }, mode: "cors" }).then(r => r.json())); |
|||
} |
|||
ps().catch(console.error); |
|||
} |
|||
|
|||
let newComment = async () => { |
|||
let token = localStorage.getItem("Token"); |
|||
let y = document.getElementById("commentContent") as HTMLInputElement; |
|||
await fetch("http://127.0.0.1:3000/comment", { method: "POST", headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" }, mode: "cors", body: JSON.stringify({ "post_id": parseInt(pid), "content": y.value }) }); |
|||
y.value = ""; |
|||
refreshComments(); |
|||
} |
|||
return ( |
|||
<div> |
|||
<Dashboard /> |
|||
<main className='flex flex-col place-items-center space-y-3'> |
|||
<h1 className='text-2xl text-white'>{post.text}</h1> |
|||
<textarea id="commentContent" className="textarea textarea-secondary text-xl" placeholder="New comment..."></textarea> |
|||
<button className="btn btn-outline btn-secondary w-20" onClick={newComment}>Comment</button> |
|||
<div className="divider"></div> |
|||
<div className='pt-10'> |
|||
{comments["Array"] != undefined ? comments["Array"].map(comment => { |
|||
return ( |
|||
<CommentBox content={comment["Text"]} user={comment["Commenter"]} id={comment["PostId"]} /> |
|||
); |
|||
}) : ""} |
|||
</div> |
|||
</main> |
|||
</div> |
|||
) |
|||
} |
@ -0,0 +1,27 @@ |
|||
import { useState, useEffect } from 'react'; |
|||
import { useRouter } from 'next/router'; |
|||
import Dashboard from '../components/ui'; |
|||
let url = process.env.NEXT_PUBLIC_URL |
|||
|
|||
export default function Profile() { |
|||
const router = useRouter(); |
|||
|
|||
let [info, setInfo] = useState({}); |
|||
useEffect(() => { |
|||
let token = localStorage.getItem("Token"); |
|||
let ps = async () => { |
|||
setInfo(await fetch(`http://${url}/profile`, { headers: { "Authorization": `Bearer ${token}` }, mode: "cors" }).then(r => r.json())); |
|||
} |
|||
ps().catch(console.error); |
|||
}, [router.isReady]); |
|||
return ( |
|||
<div> |
|||
<Dashboard /> |
|||
<main className='flex flex-col place-items-center space-y-3'> |
|||
<h1 className='text-2xl text-white'>{info["Username"]}</h1> |
|||
<p>About me :</p> |
|||
<p className='text-xl'>woops... </p> |
|||
</main> |
|||
</div> |
|||
) |
|||
} |
@ -0,0 +1,29 @@ |
|||
import { useState, useEffect } from 'react'; |
|||
import { useRouter } from 'next/router'; |
|||
import Dashboard from '../../components/ui'; |
|||
|
|||
|
|||
export default function Profile() { |
|||
const router = useRouter(); |
|||
|
|||
let [info, setInfo] = useState({}); |
|||
useEffect(() => { |
|||
if (!router.isReady) return; |
|||
let token = localStorage.getItem("Token"); |
|||
let ps = async () => { |
|||
const { pid } = router.query |
|||
setInfo(await fetch(`http://127.0.0.1:3000/profile/${pid}`, { headers: { "Authorization": `Bearer ${token}` }, mode: "cors" }).then(r => r.json())); |
|||
} |
|||
ps().catch(console.error); |
|||
}, [router.isReady]); |
|||
return ( |
|||
<div> |
|||
<Dashboard /> |
|||
<main className='flex flex-col place-items-center space-y-3'> |
|||
<h1 className='text-2xl text-white'>{info["Username"]}</h1> |
|||
<p>About me :</p> |
|||
<p className='text-xl'>woops... </p> |
|||
</main> |
|||
</div> |
|||
) |
|||
} |
@ -0,0 +1,38 @@ |
|||
import { useRouter } from 'next/router'; |
|||
let url = process.env.NEXT_PUBLIC_URL |
|||
|
|||
export default function Signup() { |
|||
const router = useRouter(); |
|||
|
|||
let signup = async (event) => { |
|||
event.preventDefault(); |
|||
let username = (document.getElementById("Username") as HTMLInputElement).value; |
|||
let email = (document.getElementById("Email") as HTMLInputElement).value; |
|||
let password = (document.getElementById("Password") as HTMLInputElement).value; |
|||
|
|||
let token = await fetch(`http://${url}/signup`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, mode: "cors", body: JSON.stringify({ "Username": username, "Email": email, "Password": password }) }).then(r => r.json()); |
|||
if (token["status"] == "ok") { |
|||
router.push('/login') |
|||
} |
|||
else { |
|||
alert("Username already exists"); |
|||
} |
|||
} |
|||
|
|||
return ( |
|||
<div> |
|||
<header className='grid place-items-center pt-5 pb-20 text-white text-xl'> |
|||
<h1>Copybook</h1> |
|||
</header> |
|||
|
|||
<form className="grid place-items-center space-y-5" action="#" onSubmit={signup}> |
|||
<h1 className="text-xl pb-5">Signup</h1> |
|||
<input id="Email" type="text" placeholder="Email" className="input input-bordered input-accent w-full max-w-xs text-xl" /> |
|||
<input id="Username" type="text" placeholder="Username" className="input input-bordered input-accent w-full max-w-xs text-xl" /> |
|||
<input id="Password" type="password" placeholder="Password" className="input input-bordered input-primary w-full max-w-xs text-xl" /> |
|||
<button className="btn btn-outline btn-accent w-40">Signup</button> |
|||
</form> |
|||
</div> |
|||
|
|||
) |
|||
} |
@ -0,0 +1,6 @@ |
|||
module.exports = { |
|||
plugins: { |
|||
tailwindcss: {}, |
|||
autoprefixer: {}, |
|||
}, |
|||
} |
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 5.9 KiB |
After Width: | Height: | Size: 13 KiB |
After Width: | Height: | Size: 268 B |
After Width: | Height: | Size: 9.4 KiB |
After Width: | Height: | Size: 12 KiB |
After Width: | Height: | Size: 7.4 KiB |
After Width: | Height: | Size: 9.4 KiB |
After Width: | Height: | Size: 13 KiB |
After Width: | Height: | Size: 8.9 KiB |
After Width: | Height: | Size: 5.9 KiB |
After Width: | Height: | Size: 2.1 KiB |
After Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 5.1 KiB |
After Width: | Height: | Size: 5.9 KiB |
After Width: | Height: | Size: 5.9 KiB |
@ -0,0 +1,116 @@ |
|||
.container { |
|||
padding: 0 2rem; |
|||
} |
|||
|
|||
.main { |
|||
min-height: 100vh; |
|||
padding: 4rem 0; |
|||
flex: 1; |
|||
display: flex; |
|||
flex-direction: column; |
|||
justify-content: center; |
|||
align-items: center; |
|||
} |
|||
|
|||
.footer { |
|||
display: flex; |
|||
flex: 1; |
|||
padding: 2rem 0; |
|||
border-top: 1px solid #eaeaea; |
|||
justify-content: center; |
|||
align-items: center; |
|||
} |
|||
|
|||
.footer a { |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
flex-grow: 1; |
|||
} |
|||
|
|||
.title a { |
|||
color: #0070f3; |
|||
text-decoration: none; |
|||
} |
|||
|
|||
.title a:hover, |
|||
.title a:focus, |
|||
.title a:active { |
|||
text-decoration: underline; |
|||
} |
|||
|
|||
.title { |
|||
margin: 0; |
|||
line-height: 1.15; |
|||
font-size: 4rem; |
|||
} |
|||
|
|||
.title, |
|||
.description { |
|||
text-align: center; |
|||
} |
|||
|
|||
.description { |
|||
margin: 4rem 0; |
|||
line-height: 1.5; |
|||
font-size: 1.5rem; |
|||
} |
|||
|
|||
.code { |
|||
background: #fafafa; |
|||
border-radius: 5px; |
|||
padding: 0.75rem; |
|||
font-size: 1.1rem; |
|||
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, |
|||
Bitstream Vera Sans Mono, Courier New, monospace; |
|||
} |
|||
|
|||
.grid { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
flex-wrap: wrap; |
|||
max-width: 800px; |
|||
} |
|||
|
|||
.card { |
|||
margin: 1rem; |
|||
padding: 1.5rem; |
|||
text-align: left; |
|||
color: inherit; |
|||
text-decoration: none; |
|||
border: 1px solid #eaeaea; |
|||
border-radius: 10px; |
|||
transition: color 0.15s ease, border-color 0.15s ease; |
|||
max-width: 300px; |
|||
} |
|||
|
|||
.card:hover, |
|||
.card:focus, |
|||
.card:active { |
|||
color: #0070f3; |
|||
border-color: #0070f3; |
|||
} |
|||
|
|||
.card h2 { |
|||
margin: 0 0 1rem 0; |
|||
font-size: 1.5rem; |
|||
} |
|||
|
|||
.card p { |
|||
margin: 0; |
|||
font-size: 1.25rem; |
|||
line-height: 1.5; |
|||
} |
|||
|
|||
.logo { |
|||
height: 1em; |
|||
margin-left: 0.5rem; |
|||
} |
|||
|
|||
@media (max-width: 600px) { |
|||
.grid { |
|||
width: 100%; |
|||
flex-direction: column; |
|||
} |
|||
} |
@ -0,0 +1,20 @@ |
|||
@tailwind base; |
|||
@tailwind components; |
|||
@tailwind utilities; |
|||
|
|||
html, |
|||
body { |
|||
padding: 0; |
|||
margin: 0; |
|||
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, |
|||
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; |
|||
} |
|||
|
|||
a { |
|||
color: inherit; |
|||
text-decoration: none; |
|||
} |
|||
|
|||
* { |
|||
box-sizing: border-box; |
|||
} |
@ -0,0 +1,27 @@ |
|||
module.exports = { |
|||
content: [ |
|||
"./pages/**/*.{js,ts,jsx,tsx}", |
|||
"./components/**/*.{js,ts,jsx,tsx}", |
|||
], |
|||
theme: { |
|||
extend: {}, |
|||
}, |
|||
daisyui: { |
|||
themes: [ |
|||
{ |
|||
mytheme: { |
|||
"primary": "#e26394", |
|||
"secondary": "#76e8f7", |
|||
"accent": "#ed44a6", |
|||
"neutral": "#16191D", |
|||
"base-100": "#2F354C", |
|||
"info": "#95AAE4", |
|||
"success": "#12735F", |
|||
"warning": "#F3DA58", |
|||
"error": "#EB333F", |
|||
}, |
|||
}, |
|||
], |
|||
}, |
|||
plugins: [require("daisyui")], |
|||
} |
@ -0,0 +1,30 @@ |
|||
{ |
|||
"compilerOptions": { |
|||
"target": "es5", |
|||
"lib": [ |
|||
"dom", |
|||
"dom.iterable", |
|||
"esnext" |
|||
], |
|||
"allowJs": true, |
|||
"skipLibCheck": true, |
|||
"strict": false, |
|||
"forceConsistentCasingInFileNames": true, |
|||
"noEmit": true, |
|||
"incremental": true, |
|||
"esModuleInterop": true, |
|||
"module": "esnext", |
|||
"resolveJsonModule": true, |
|||
"moduleResolution": "node", |
|||
"isolatedModules": true, |
|||
"jsx": "preserve" |
|||
}, |
|||
"include": [ |
|||
"next-env.d.ts", |
|||
"**/*.ts", |
|||
"**/*.tsx" |
|||
], |
|||
"exclude": [ |
|||
"node_modules" |
|||
] |
|||
} |
@ -0,0 +1,22 @@ |
|||
events { |
|||
worker_connections 1024; |
|||
} |
|||
|
|||
http { |
|||
|
|||
server { |
|||
listen 80; |
|||
server_name social.org; |
|||
location / { |
|||
proxy_pass http://frontend:3000; |
|||
} |
|||
} |
|||
|
|||
server { |
|||
listen 80; |
|||
server_name api.social.org; |
|||
location / { |
|||
proxy_pass http://api:3000; |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,3 @@ |
|||
#!/bin/bash |
|||
|
|||
sudo docker rmi social-network-nginx social-network-frontend |
After Width: | Height: | Size: 19 KiB |
After Width: | Height: | Size: 26 KiB |
After Width: | Height: | Size: 29 KiB |
After Width: | Height: | Size: 46 KiB |
@ -0,0 +1,3 @@ |
|||
#!/bin/bash |
|||
|
|||
sudo docker compose up -d |
@ -0,0 +1,3 @@ |
|||
#!/bin/bash |
|||
|
|||
sudo docker compose down |