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

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