Server code

This commit is contained in:
2024-11-20 22:31:39 +00:00
parent 6358a7a595
commit c4e4affa7c
10 changed files with 4827 additions and 0 deletions

87
.gitignore vendored Normal file
View 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

File diff suppressed because it is too large Load Diff

13
client/package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

15
server/package.json Normal file
View 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
View 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 });
});
}

View 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
View 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
View 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
View File