Server code
This commit is contained in:
87
.gitignore
vendored
Normal file
87
.gitignore
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Build outputs
|
||||
/client/build/
|
||||
/client/dist/
|
||||
/server/dist/
|
||||
|
||||
# Debug logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
debug.log
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Testing
|
||||
/coverage
|
||||
.nyc_output
|
||||
|
||||
# Production
|
||||
/build
|
||||
|
||||
# Uploaded files
|
||||
/server/uploads/*
|
||||
!/server/uploads/.gitkeep
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
*.code-workspace
|
||||
|
||||
# IDE - IntelliJ
|
||||
.idea/
|
||||
*.iml
|
||||
*.iws
|
||||
.idea_modules/
|
||||
|
||||
# IDE - WebStorm
|
||||
.idea/
|
||||
*.iml
|
||||
*.ipr
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
Icon
|
||||
._*
|
||||
|
||||
# Windows
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Linux
|
||||
*~
|
||||
.fuse_hidden*
|
||||
.directory
|
||||
.Trash-*
|
||||
|
||||
# Temporary files
|
||||
*.swp
|
||||
*.swo
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
2739
client/package-lock.json
generated
Normal file
2739
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
13
client/package.json
Normal file
13
client/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "blog-client",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@uiw/react-md-editor": "^3.6.0",
|
||||
"axios": "^1.0.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.0.0",
|
||||
"sass": "^1.45.0"
|
||||
}
|
||||
}
|
||||
1749
server/package-lock.json
generated
Normal file
1749
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
server/package.json
Normal file
15
server/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "blog-server",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.0.0",
|
||||
"@fastify/jwt": "^7.0.0",
|
||||
"@fastify/multipart": "^7.0.0",
|
||||
"@fastify/static": "^6.0.0",
|
||||
"bcrypt": "^5.0.1",
|
||||
"fastify": "^4.0.0",
|
||||
"marked": "^4.0.0",
|
||||
"pg": "^8.7.1",
|
||||
"slugify": "^1.6.0"
|
||||
}
|
||||
}
|
||||
27
server/src/routes/auth.js
Normal file
27
server/src/routes/auth.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import bcrypt from 'bcrypt';
|
||||
import { db } from '../db.js';
|
||||
|
||||
export default async function auth(fastify, options) {
|
||||
fastify.post('/api/auth/login', async (request, reply) => {
|
||||
const { username, password } = request.body;
|
||||
|
||||
const user = await db.oneOrNone(
|
||||
'SELECT * FROM users WHERE username = $1',
|
||||
[username]
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
reply.code(401).send({ error: 'Invalid credentials' });
|
||||
return;
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.password_hash);
|
||||
if (!valid) {
|
||||
reply.code(401).send({ error: 'Invalid credentials' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = fastify.jwt.sign({ id: user.id });
|
||||
reply.send({ token });
|
||||
});
|
||||
}
|
||||
33
server/src/routes/images.js
Normal file
33
server/src/routes/images.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createWriteStream } from 'fs';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { join } from 'path';
|
||||
import { db } from '../db.js';
|
||||
|
||||
export default async function images(fastify, options) {
|
||||
fastify.addHook('preHandler', async (request, reply) => {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch (err) {
|
||||
reply.send(err);
|
||||
}
|
||||
});
|
||||
|
||||
fastify.post('/api/images/:postId', async (request, reply) => {
|
||||
const { postId } = request.params;
|
||||
const data = await request.file();
|
||||
|
||||
const filename = `${Date.now()}-${data.filename}`;
|
||||
const path = join(__dirname, '../../uploads', filename);
|
||||
|
||||
await pipeline(data.file, createWriteStream(path));
|
||||
|
||||
const image = await db.one(
|
||||
`INSERT INTO images (filename, url, post_id)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *`,
|
||||
[filename, `/uploads/${filename}`, postId]
|
||||
);
|
||||
|
||||
reply.send(image);
|
||||
});
|
||||
}
|
||||
124
server/src/routes/posts.js
Normal file
124
server/src/routes/posts.js
Normal file
@@ -0,0 +1,124 @@
|
||||
import { marked } from 'marked';
|
||||
import slugify from 'slugify';
|
||||
import { db } from '../db.js';
|
||||
|
||||
export default async function posts(fastify, options) {
|
||||
fastify.addHook('preHandler', async (request, reply) => {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch (err) {
|
||||
reply.send(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Create post
|
||||
fastify.post('/api/posts', async (request, reply) => {
|
||||
const { title, content, status, publishedAt, tags } = request.body;
|
||||
const slug = slugify(title, { lower: true });
|
||||
const compiledContent = marked(content);
|
||||
|
||||
const post = await db.tx(async t => {
|
||||
const post = await t.one(
|
||||
`INSERT INTO posts
|
||||
(title, slug, markdown_content, compiled_content, status, published_at, author_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *`,
|
||||
[title, slug, content, compiledContent, status, publishedAt, request.user.id]
|
||||
);
|
||||
|
||||
if (tags && tags.length) {
|
||||
for (const tagName of tags) {
|
||||
const tag = await t.oneOrNone(
|
||||
'SELECT * FROM tags WHERE name = $1',
|
||||
[tagName]
|
||||
);
|
||||
|
||||
const tagId = tag ? tag.id : (
|
||||
await t.one(
|
||||
'INSERT INTO tags (name, slug) VALUES ($1, $2) RETURNING id',
|
||||
[tagName, slugify(tagName, { lower: true })]
|
||||
)
|
||||
).id;
|
||||
|
||||
await t.none(
|
||||
'INSERT INTO posts_tags (post_id, tag_id) VALUES ($1, $2)',
|
||||
[post.id, tagId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return post;
|
||||
});
|
||||
|
||||
reply.send(post);
|
||||
});
|
||||
|
||||
// Get all posts
|
||||
fastify.get('/api/posts', async (request, reply) => {
|
||||
const posts = await db.any(`
|
||||
SELECT p.*,
|
||||
array_agg(DISTINCT t.name) as tags,
|
||||
array_agg(DISTINCT i.url) as images
|
||||
FROM posts p
|
||||
LEFT JOIN posts_tags pt ON p.id = pt.post_id
|
||||
LEFT JOIN tags t ON pt.tag_id = t.id
|
||||
LEFT JOIN images i ON p.id = i.post_id
|
||||
WHERE p.author_id = $1
|
||||
GROUP BY p.id
|
||||
ORDER BY p.created_at DESC
|
||||
`, [request.user.id]);
|
||||
|
||||
reply.send(posts);
|
||||
});
|
||||
|
||||
// Update post
|
||||
fastify.put('/api/posts/:id', async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const { title, content, status, publishedAt, tags } = request.body;
|
||||
const compiledContent = marked(content);
|
||||
|
||||
const post = await db.tx(async t => {
|
||||
const post = await t.one(
|
||||
`UPDATE posts
|
||||
SET title = $1,
|
||||
markdown_content = $2,
|
||||
compiled_content = $3,
|
||||
status = $4,
|
||||
published_at = $5,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $6 AND author_id = $7
|
||||
RETURNING *`,
|
||||
[title, content, compiledContent, status, publishedAt, id, request.user.id]
|
||||
);
|
||||
|
||||
// Remove existing tags
|
||||
await t.none('DELETE FROM posts_tags WHERE post_id = $1', [id]);
|
||||
|
||||
// Add new tags
|
||||
if (tags && tags.length) {
|
||||
for (const tagName of tags) {
|
||||
const tag = await t.oneOrNone(
|
||||
'SELECT * FROM tags WHERE name = $1',
|
||||
[tagName]
|
||||
);
|
||||
|
||||
const tagId = tag ? tag.id : (
|
||||
await t.one(
|
||||
'INSERT INTO tags (name, slug) VALUES ($1, $2) RETURNING id',
|
||||
[tagName, slugify(tagName, { lower: true })]
|
||||
)
|
||||
).id;
|
||||
|
||||
await t.none(
|
||||
'INSERT INTO posts_tags (post_id, tag_id) VALUES ($1, $2)',
|
||||
[id, tagId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return post;
|
||||
});
|
||||
|
||||
reply.send(post);
|
||||
});
|
||||
}
|
||||
40
server/src/server.js
Normal file
40
server/src/server.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import jwt from '@fastify/jwt';
|
||||
import multipart from '@fastify/multipart';
|
||||
import static from '@fastify/static';
|
||||
import { join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
app.register(cors, {
|
||||
origin: process.env.FRONTEND_URL || 'http://localhost:3000'
|
||||
});
|
||||
|
||||
app.register(jwt, {
|
||||
secret: process.env.JWT_SECRET
|
||||
});
|
||||
|
||||
app.register(multipart);
|
||||
|
||||
app.register(static, {
|
||||
root: join(fileURLToPath(import.meta.url), '../../uploads'),
|
||||
prefix: '/uploads/'
|
||||
});
|
||||
|
||||
app.register(import('./routes/auth.js'));
|
||||
app.register(import('./routes/posts.js'));
|
||||
app.register(import('./routes/tags.js'));
|
||||
app.register(import('./routes/images.js'));
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
await app.listen({ port: 3001 });
|
||||
} catch (err) {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
start();
|
||||
0
server/uploads/.gitkeep
Normal file
0
server/uploads/.gitkeep
Normal file
Reference in New Issue
Block a user