Commit ffc33ea8 authored by Erick Weil's avatar Erick Weil

Agora fotos são enviadas separadas, username do usuário é imutável

parent 43eea6ec
......@@ -6,20 +6,19 @@ Exemplo de Aplicação Web que funciona como Rede social.
- Usuário
* **Criar conta** - Possível criar conta utilizando email e senha
* **Fazer Login** - Antes de utilizar qualquer rota da api é necessário autenticar
* **Deletar conta** - Caso deseje, é possível deletar a sua própria conta (Deletar a conta não deleta postagens)
* **Deletar conta** - Caso deseje, é possível deletar a sua própria conta (Deletar a conta não deleta discussões nem comentários realizados)
* **Pesquisar usuários** - Pesquise por uma lista de usuário pelo nome ou um usuário pelo id
* **Atualizar perfil** - Atualize seu nome e biografia, além da foto de perfil e capa
- Seguidor
* **Seguir** - Siga ou Deixe de seguir um usuário
* **Listar Seguidores** - Liste os seguidores ou os seguindo de um usuário
* **Contar Seguidores** - Conte o número de seguidores e seguindo de um usuário
- Postagem
* **Pesquisar postagens** - É possível pesquisar postagens por vários filtros, pelo id, mais recentes, por palavras no conteudo, pelos hashtags, respostas de uma postagem, postagens de um usuário, respostas de um usuário.
* **Realizar postagem** - Envie texto e/ou até 8 imagens em sua postagem. Também pode fazer uma postagem como resposta à outra
* **Deletar postagem** - Deletar uma postagem irá apagar todo seu conteúdo e imagens, porém continuará na listagem caso seja uma resposta a outra postagem
- Discussão
* **Pesquisar discussões** - É possível pesquisar discussões, pelo id, por palavras no título, pelos assuntos e discussões de um usuário
* **Criar discussão** - Permite criar uma nova discussão, que possui um título e um comentário inicial. É possível editar a discussão depois.
* **Deletar discussão** - Deletar uma discussão irá apagar ocultar seu título, porém seus comentários continuam, permitindo ainda criar novos comentários
- Comentário
* **Pesquisar comentário** - É possível pesquisar comentários pelo id, por palavras no conteúdo, por usuários citados, e por usuário
* **Realizar comentário** - Envie texto e até 8 imagens em seu comentário. Também pode citar outro comentário. É possível editar o comentário.
* **Deletar comentário** - Deletar um comentário irá ocultar seu conteúdo e imagens, porém continuará listado como um comentário da discussão
- Curtir
* **Curtir postagens** - Curta/Descurta postagens
* **Listar Curtidas** - Liste as postagens curtidas por você
* **Curtir comentários** - Positive/Negative comentários
## Criar Conta
Deve informar o **nome**, **email** e **senha** do usuário a ser criado
......@@ -110,8 +109,8 @@ um único resultado: João da Silva
Para mais informações, veja a documentação sobre o índice de texto do mongodb. Veja: [https://www.mongodb.com/docs/manual/core/index-text/](https://www.mongodb.com/docs/manual/core/index-text/)
### Pesquisa por hashtag
No caso de postagens onde é possível pesquisar hashtags, outro procedimento é realizado: no momento que a postagem é criada, as hashtags são encontradas, processadas pelo [any-ascii](https://github.com/anyascii/anyascii) para remover todos os caracteres especiais e acentos e então convertida para minúsculas antes de finalmente serem salvas no registro da postagem como um array no campo **hashtags**. Ao pesquisar por com hashtag, a pesquisa ignora acentos, maiúsculas e minúsculas pois a sua pesquisa será tratada da mesma forma antes de realizar uma busca por que postagens possuem no array de hashtags esta exata hashtag.
### Pesquisa por Assunto/Menções
No caso de discussões onde é possível pesquisar assuntos e comentários pesquisa por menções, outro procedimento é realizado: no momento que a postagem é criada, as assuntos são encontradas, processadas pelo [any-ascii](https://github.com/anyascii/anyascii) para remover todos os caracteres especiais e acentos e então convertida para minúsculas antes de finalmente serem salvas no registro da postagem como um array no campo **assuntos**. Ao pesquisar por com assunto, a pesquisa ignora acentos, maiúsculas e minúsculas pois a sua pesquisa será tratada da mesma forma antes de realizar uma busca por que postagens possuem no array de assuntos esta exata assunto.
## Deletar Elementos
Nem todas as rotas que deletam *realmente* deletam por completo o que foi solicitado.
......@@ -123,19 +122,23 @@ Nem todas as rotas que deletam *realmente* deletam por completo o que foi solici
Será deletado a sua entrada do usuário como também:
- As imagens de capa e perfil
- Removido da listagem de seguindo de todos que seguem você
- Removido da listagem de seguidores de todos que você segue
Porém **não serão removidos** as suas postagens/respostas. Apenas ficará o autor da postagem como [deletado]
Porém **não serão removidos** as suas discussões/comentários. Apenas ficará o autor como [deletado]
Também as postagens que você curtiu continuarão com a sua curtida registrada
- **Deletar Postagem**
Também as postagens que você positivou/negativou continuarão com a sua curtida registrada
- **Editar Comentário**
Deletar uma postagem irá apagar todo seu conteúdo e imagens, porém continuará na listagem caso seja uma resposta a outra postagem.
Para todos os efeitos, o comentário foi editado. será possível saber que o comentário foi editado e qual a data da última edição.
Uma postagem deletada não é exibida mais na pesquisa de postagens do usuário nem na página 'For You', porém caso o usuário possua o link da postagem ainda será possível visualizar que ela existe e que usuário foi o autor, porém sem conteúdo nem as imagens( Não é possível acessar as imagens nem mesmo com o link original pois estarão deletadas). Respostas a esta postagem porém irão ser visíveis e podem ser encontradas na pesquisa de respostas de um usuário.
Internamente, um administrador ainda pode ter acesso a todo o histórico de edições de um comentário.
- **Deletar Comentário**
Deletar um Comentário é como uma edição que remove seu conteúdo e imagens, além de ser marcado como **deletado**.
Um comentário deletado não é exibido mais na pesquisa de comentários do usuário, porém continua na listagem da discussão. Ainda será possível visualizar que ele existe e que usuário foi o autor, porém sem conteúdo nem as imagens. (Comentários que citaram antes de ser deletado continuarão com o conteúdo antigo).
Deletar a postagem que é resposta a outra não modifica a contagem de respostas dela.
Deletar o comentário não modifica a contagem de comentários da postagem.
# Instalação
Para executar esta API localmente, é possível utilizar **Docker** ou então executando via node direto.
......
import mongoose from "mongoose";
import Comentario from "../models/Comentario.js";
import Discussao from "../models/Discussao.js";
import { salvarFotoUsuario } from "../utils/foto.js";
import anyAscii from "any-ascii";
import Curtida from "../models/Curtida.js";
import { checarImagem } from "../utils/foto.js";
export default class ComentarioController {
......@@ -135,38 +135,29 @@ export default class ComentarioController {
const idUsuario = usuario._id;
const idComentario = req.params.id;
const conteudo = req.fields && req.fields.conteudo ? req.fields.conteudo[0] : undefined;
const conteudo = req.body ? req.body.conteudo : undefined;
const imagens = req.body ? req.body.imagens : undefined;
//if(mongoose.Types.ObjectId.isValid(idComentario) === false)
// return res.status(400).json({ error: true, message: "ID inválido" });
//const comentario = await Comentario.findOne({_id: idComentario, deletado: false}); // não pode editar um comentário deletado
//if(!comentario)
// return res.status(404).json({ error: true, message: "Comentario não encontrado" });
// A FAZER: Só deveria salvar as imagens que são diferentes das que já foram enviadas
// do jeito que está aqui, vai ficar copiando as imagens a cada edição
let imagens = [];
let imagens_path = [];
if(req.files && req.files.length > 0) {
imagens = req.files;
for(let imagem of imagens) {
const filepath = await salvarFotoUsuario(imagem,idUsuario,{});
imagens_path.push(filepath);
}
if(imagens) for(let imagem of imagens) {
if(!checarImagem(idUsuario, imagem)) return res.status(400).json({ error: true, validation: {
imagem: "Imagem inválida: "+imagem
}});
}
let resultEditar = await Comentario.editarComentario(idUsuario,{
comentario: idComentario,
conteudo: conteudo,
imagens: imagens_path
imagens: imagens
});
if(!resultEditar.sucesso) {
return res.status(400).json({ error: true, validation: resultEditar.validation });
}
return res.status(201).json(resultEditar.comentario);
let comentario = resultEditar.comentario;
comentario.edicoes = undefined; // para não enviar o histórico de edições para o front-end
return res.status(201).json(comentario);
}
......@@ -175,23 +166,19 @@ export default class ComentarioController {
const idUsuario = usuario._id;
const idDiscussao = req.params.id;
const idCitacao = req.fields && req.fields.citacao ? req.fields.citacao[0] : undefined;
const conteudo = req.fields && req.fields.conteudo ? req.fields.conteudo[0] : undefined;
const idCitacao = req.body ? req.body.citacao : undefined;
const conteudo = req.body ? req.body.conteudo : undefined;
const imagens = req.body ? req.body.imagens : undefined;
// Essa validação tem que ser feita aqui porque no método do model direto isso pode
if(!idDiscussao) return res.status(400).json({ error: true, validation: {
discussao: "Não pode realizar comentário sem especificar uma discussão"
}});
let imagens = [];
let imagens_path = [];
if(req.files && req.files.length > 0) {
imagens = req.files;
for(let imagem of imagens) {
const filepath = await salvarFotoUsuario(imagem,idUsuario,{});
imagens_path.push(filepath);
}
if(imagens) for(let imagem of imagens) {
if(!checarImagem(idUsuario, imagem)) return res.status(400).json({ error: true, validation: {
imagem: "Imagem inválida: "+imagem
}});
}
let resultCriar = await Comentario.criarComentario({
......
import mongoose from "mongoose";
import Comentario from "../models/Comentario.js";
import Discussao from "../models/Discussao.js";
import { salvarFotoUsuario } from "../utils/foto.js";
import { checarImagem } from "../utils/foto.js";
export default class DiscussaoControler {
static async _listarDiscussoes(req, res, filtrar) {
......@@ -140,23 +140,15 @@ export default class DiscussaoControler {
const usuario = req.usuario;
const idUsuario = usuario._id;
const titulo = req.fields && req.fields.titulo ? req.fields.titulo[0] : undefined;
const conteudo = req.fields && req.fields.conteudo ? req.fields.conteudo[0] : undefined;
let assuntos = req.fields && req.fields.assuntos ? req.fields.assuntos[0] : undefined;
const titulo = req.body ? req.body.titulo : undefined;
const conteudo = req.body ? req.body.conteudo : undefined;
const assuntos = req.body ? req.body.assuntos : undefined;
const imagens = req.body ? req.body.imagens : undefined;
if(assuntos !== undefined) {
assuntos = assuntos.split(",");
}
let imagens = [];
let imagens_path = [];
if(req.files && req.files.length > 0) {
imagens = req.files;
for(let imagem of imagens) {
const filepath = await salvarFotoUsuario(imagem,idUsuario,{});
imagens_path.push(filepath);
}
if(imagens) for(let imagem of imagens) {
if(!checarImagem(idUsuario, imagem)) return res.status(400).json({ error: true, validation: {
imagem: "Imagem inválida: "+imagem
}});
}
let resultCriar = await Discussao.criarDiscussao({
......@@ -165,7 +157,7 @@ export default class DiscussaoControler {
assuntos: assuntos,
edicao: {
conteudo: conteudo,
imagens: imagens_path
imagens: imagens
}
});
......
import mongoose from "mongoose";
import path from "path";
import { checarImagem, fotoDir, salvarFotoUsuario } from "../utils/foto.js";
export default class FotoController {
static async enviarImagem(req, res) {
const usuario = req.usuario;
const idUsuario = usuario._id;
if(!req.file) {
return res.status(400).json({ message: "Deve enviar 1 foto para realizar upload" });
}
let imagem = req.file;
const tipo = req.fields && req.fields.tipo ? req.fields.tipo[0] : undefined;
let opcoesImagem;
if(tipo === "perfil") {
opcoesImagem = {
// Foto de perfil é quadrada
minAspectRatio: 1.0,
maxAspectRatio: 1.0,
};
} else if(tipo === "capa") {
opcoesImagem = {
// Foto de de capa é no mínimo quadrada e idealmente paisagem
minAspectRatio: 1.0
};
} else if(tipo === "comentario") {
opcoesImagem = {}; // padrão é suficiente
} else {
return res.status(400).json({ message: "Deve especificar o tipo da imagem como 'perfil', 'capa' ou 'comentario'" });
}
const filepath = await salvarFotoUsuario(imagem,idUsuario,opcoesImagem);
return res.status(200).json({
url: filepath
});
}
static async getImagem(req, res) {
const idUsuario = req.params.id;
const filename = req.params.img;
if(mongoose.Types.ObjectId.isValid(idUsuario) === false)
return res.status(400).json({ error: true, message: "ID inválido" });
const filepath = "/img/"+idUsuario+"/"+filename;
if(!checarImagem(idUsuario, filepath)) {
return res.status(400).json({ error: true, message: "Caminho inválido" });
}
return res.status(200)
.sendFile(fotoDir+filepath,{
root: path.resolve()
});
}
}
\ No newline at end of file
import Usuario from "../models/Usuario.js";
import Usuario, { usernameRegex } from "../models/Usuario.js";
import mongoose from "mongoose";
import { deletarFotoUsuario, salvarFotoUsuario } from "../utils/foto.js";
import { deletarFotoUsuario } from "../utils/foto.js";
import anyAscii from "any-ascii";
export default class UsuarioControler {
......@@ -31,7 +31,7 @@ export default class UsuarioControler {
if(filtrarUsername) {
filtrarUsername = anyAscii(filtrarUsername).toLowerCase();
if(!/^[a-z](_?[0-9a-z])*$/.test(filtrarUsername)) {
if(!usernameRegex.test(filtrarUsername)) {
return res.status(400).json({error: true, message: "o nome de usuário deve conter apenas letras e números, separados por _ (underline)"});
}
......@@ -73,15 +73,27 @@ export default class UsuarioControler {
// A FAZER: Não deveria mostrar as preferências para o usuário quando é ele próprio solicitando?
static async listarUsuarioPorId(req,res) {
const id = req.params.id;
if(mongoose.Types.ObjectId.isValid(id) === false)
return res.status(400).json({ error: true, message: "ID inválido" });
const usuario = await Usuario.findById(id);
if(!usuario)
return res.status(404).json({ error: true, message: "Usuário não encontrado" });
return res.status(200).json(usuario);
if(mongoose.Types.ObjectId.isValid(id) === false) {
let filtrarUsername = id;
if(!usernameRegex.test(filtrarUsername)) {
return res.status(400).json({error: true, message: "ID ou nome de usuário inválido"});
}
const usuario = await Usuario.findOne({username: filtrarUsername});
if(!usuario)
return res.status(404).json({ error: true, message: "Usuário não encontrado" });
return res.status(200).json(usuario);
}
else {
const usuario = await Usuario.findById(id);
if(!usuario)
return res.status(404).json({ error: true, message: "Usuário não encontrado" });
return res.status(200).json(usuario);
}
}
// Rota para conferir se o token é válido e obter informações atualizadas do usuário
......@@ -114,8 +126,10 @@ export default class UsuarioControler {
return res.status(201).json(resultCriar.usuario);
}
static async atualizarUsuario(req,res) {
const idUsuario = req.usuario._id;
const atualizar = req.body;
static async _atualizarUsuario(idUsuario,atualizar,res) {
let resultAtualizar = await Usuario.atualizarUsuario(
idUsuario,
atualizar
......@@ -137,51 +151,6 @@ export default class UsuarioControler {
return res.status(200).json(resultAtualizar.usuario);
}
static async atualizarUsuario(req,res) {
const atualizar = req.body;
if(atualizar.fotoPerfil !== undefined || atualizar.fotoCapa !== undefined) {
return res.status(400).json({ error: true, message: "Não é possível atualizar a foto assim, utilize a rota dedicada" });
}
return await UsuarioControler._atualizarUsuario(req.usuario._id,atualizar,res);
}
static async atualizarFotoPerfil(req,res) {
if(!req.file) {
return res.status(400).json({ error: true, message: "Não enviou nenhuma imagem" });
}
const idUsuario = req.usuario._id;
const filepath = await salvarFotoUsuario(req.file,idUsuario,{
// Foto de perfil é quadrada
minAspectRatio: 1.0,
maxAspectRatio: 1.0,
});
return await UsuarioControler._atualizarUsuario(idUsuario,{
fotoPerfil: filepath
},res);
}
static async atualizarFotoCapa(req,res) {
if(!req.file) {
return res.status(400).json({ error: true, message: "Não enviou nenhuma imagem" });
}
const idUsuario = req.usuario._id;
const filepath = await salvarFotoUsuario(req.file,idUsuario,{
// Foto de de capa é no mínimo quadrada e idealmente paisagem
minAspectRatio: 1.0
});
return await UsuarioControler._atualizarUsuario(idUsuario,{
fotoCapa: filepath
},res);
}
static async deletarUsuario(req,res) {
const email = req.usuario.email;
const senha = req.body.senha;
......
// NÃO UTILIZAR MULTER
// https://picnature.de/how-to-upload-files-in-nodejs-using-multer-2-0/
// https://github.com/expressjs/multer/tree/v2.0.0-rc.4
//export const upload = multer({
//limits: {
// fileSize: "8MB"
//}
//});
import formidable from "formidable";
import { acceptableFormats, tmpFotoDir } from "../utils/foto.js";
// Usando formidable para upload de arquivos
// https://github.com/node-formidable/formidable
const filterMimeType = ({name, originalFilename, mimetype}) => {
if(!mimetype || !acceptableFormats[mimetype]) {
//form.emit("error", new formidableErrors.default("invalid type", 0, 400)); // optional make form.parse error
return false;
}
return true;
};
const defaultFormidableOptions = {
maxFiles: 8,
maxFileSize: 8 * 1024 * 1024, // 8MB
uploadDir: tmpFotoDir,
filter: filterMimeType
};
const upload = {
single: (key) => async (req,res,next) => {
const form = formidable({...defaultFormidableOptions,...{maxFiles: 1}});
const [fields, files] = await form.parse(req);
// Verificar se key está em files
if(files && files[key] !== undefined && files[key].length === 1) {
req.file = files[key][0];
}
req.fields = fields;
next();
},
multiple: (key) => async (req,res,next) => {
const form = formidable(defaultFormidableOptions);
const [fields, files] = await form.parse(req);
// Verificar se key está em files
if(files && files[key] !== undefined && files[key].length > 0) {
req.files = files[key];
}
req.fields = fields;
//console.log(req.files);
//console.log(req.fields);
next();
}
};
export default upload;
\ No newline at end of file
......@@ -119,7 +119,7 @@ Comentario.statics.criarComentario = async function(comentario_info) {
// A FAZER: questão do rich-text editor, como fica isso?
const conteudo = comentario_info.edicao.conteudo;
const imagens = comentario_info.edicao.imagens;
// Extrair as menções,
// "Exemplo de texto @usuario1 @Usuário2" -> ["@usuario1","@usuario2"]
const mencoes = extractMencoes(conteudo);
......
import mongoose from "mongoose";
import bcrypt from "bcryptjs";
import validator from "validator";
export const usuarioTeste = {
nome: "João da Silva",
email: "joao@email.com",
username: "joao_silva",
senha: "ABCDabcd1234"
};
export const usernameRegex = /^[a-z](_?[0-9a-z])*$/;
// Usuário da rede social.
// Nome, email, senha
const Usuario = new mongoose.Schema({
......@@ -106,7 +116,7 @@ Usuario.statics.criarUsuario = async function(usuario_info) {
// Pode conter apenas letras e números
// Pode separar palavras com _
// Não pode começar nem terminar com _
if(!/^[a-z](_?[0-9a-z])*$/.test(usuario_info.username)) {
if(!usernameRegex.test(usuario_info.username)) {
return {sucesso: false, validation: {username: "o nome de usuário deve conter apenas letras e números, separados por _ (underline)"}};
}
......@@ -164,10 +174,6 @@ Usuario.statics.atualizarUsuario = async function(usuario_id,atualizar) {
usuario.nome = atualizar.nome;
}
if(atualizar.username !== undefined) {
usuario.username = atualizar.username;
}
if(atualizar.fotoPerfil !== undefined) usuario.fotoPerfil = atualizar.fotoPerfil;
if(atualizar.fotoCapa !== undefined) usuario.fotoCapa = atualizar.fotoCapa;
if(atualizar.biografia !== undefined) usuario.biografia = atualizar.biografia;
......@@ -194,11 +200,4 @@ Usuario.statics.atualizarUsuario = async function(usuario_id,atualizar) {
}
};
export const usuarioTeste = {
nome: "João da Silva",
email: "joao@email.com",
username: "joao_silva",
senha: "ABCDabcd1234"
};
export default mongoose.model("usuarios", Usuario);
\ No newline at end of file
import express from "express";
import { wrapException } from "./common.js";
import AuthMiddleware from "../middlewares/AuthMiddleware.js";
import { upload } from "../utils/foto.js";
import DiscussaoControler from "../controllers/DiscussaoController.js";
import ComentarioController from "../controllers/ComentarioController.js";
const router = express.Router();
......@@ -198,23 +196,23 @@ const router = express.Router();
* requestBody:
* required: true
* content:
* multipart/form-data:
* application/json:
* schema:
* type: object
* properties:
* conteudo:
* type: string
* description: Conteúdo da postagem
* example: "Teste de Postagem"
* description: Conteúdo do comentário
* example: "Teste de Comentário"
* citacao:
* type: string
* description: Id do comentário citado
* example: ""
* fotos_post:
* fotos:
* type: array
* items:
* type: string
* format: binary
* example: "/img/551137c2f9e1fac808a5f572/4d095a193e563ded.jpg"
* responses:
* 200:
* description: Realizou o comentário
......@@ -250,7 +248,7 @@ const router = express.Router();
* $ref: '#/components/schemas/Comentario'
* 404:
* description: Comentário não encontrado
* patch:
* put:
* summary: Editar um comentário
* description: |
* Mesmo funcionamento de criar novo comentário, porém irá editá-lo
......@@ -269,23 +267,19 @@ const router = express.Router();
* requestBody:
* required: true
* content:
* multipart/form-data:
* application/json:
* schema:
* type: object
* properties:
* conteudo:
* type: string
* description: Conteúdo da postagem
* example: "Teste de Postagem"
* citacao:
* type: string
* description: Id do comentário citado
* example: ""
* fotos_post:
* description: Conteúdo do comentário
* example: "Teste de Comentário"
* fotos:
* type: array
* items:
* type: string
* format: binary
* example: "/img/551137c2f9e1fac808a5f572/4d095a193e563ded.jpg"
* responses:
* 200:
* description: Editou o comentário
......@@ -409,10 +403,10 @@ router.get("/usuarios/:id/comentarios", AuthMiddleware, wrapException(Comentario
router.get("/comentarios/:id", AuthMiddleware, wrapException(ComentarioController.listarComentarioPorId));
// Comentar em uma discussão
router.post("/discussoes/:id/comentarios", AuthMiddleware,wrapException(upload.multiple("fotos_post")), wrapException(ComentarioController.realizarComentario));
router.post("/discussoes/:id/comentarios", AuthMiddleware, wrapException(ComentarioController.realizarComentario));
// Editar um comentário (só quem fez o comentário tem permissão para isso)
router.patch("/comentarios/:id", AuthMiddleware,wrapException(upload.multiple("fotos_post")), wrapException(ComentarioController.editarComentario));
router.put("/comentarios/:id", AuthMiddleware, wrapException(ComentarioController.editarComentario));
// Deletar um comentario (só quem fez o comentário tem permissão para isso)
router.delete("/comentarios/:id", AuthMiddleware, wrapException(ComentarioController.deletarComentario));
......
import express from "express";
import { wrapException } from "./common.js";
import AuthMiddleware from "../middlewares/AuthMiddleware.js";
import { upload } from "../utils/foto.js";
import DiscussaoControler from "../controllers/DiscussaoController.js";
import ComentarioController from "../controllers/ComentarioController.js";
const router = express.Router();
/**
......@@ -171,7 +169,7 @@ const router = express.Router();
* requestBody:
* required: true
* content:
* multipart/form-data:
* application/json:
* schema:
* type: object
* properties:
......@@ -187,11 +185,11 @@ const router = express.Router();
* type: array
* items:
* type: string
* fotos_post:
* fotos:
* type: array
* items:
* type: string
* format: binary
* example: "/img/551137c2f9e1fac808a5f572/4d095a193e563ded.jpg"
* responses:
* 200:
* description: Criou a Discussão
......@@ -291,7 +289,7 @@ router.get("/discussoes", AuthMiddleware, wrapException(DiscussaoControler.lista
router.get("/usuarios/:id/discussoes", AuthMiddleware, wrapException(DiscussaoControler.listarDiscussoesUsuario));
// Criar uma discussção
router.post("/discussoes", AuthMiddleware,wrapException(upload.multiple("fotos_post")), wrapException(DiscussaoControler.criarDiscussao));
router.post("/discussoes", AuthMiddleware, wrapException(DiscussaoControler.criarDiscussao));
// Listar uma única discussao
router.get("/discussoes/:id", AuthMiddleware, wrapException(DiscussaoControler.listarDiscussaoPorId));
......
import express from "express";
import { wrapException } from "./common.js";
import { getImagem } from "../utils/foto.js";
import AuthMiddleware from "../middlewares/AuthMiddleware.js";
import FotoController from "../controllers/FotoController.js";
import upload from "../middlewares/FotoMiddleware.js";
const router = express.Router();
......@@ -40,7 +42,54 @@ const router = express.Router();
* schema:
* type: string
* format: binary
*/
/**
* @swagger
* /img:
* post:
* summary: Enviar uma imagem
* description: |
* Envia 1 imagem
*
* A imagem deve ser enviada no campo **foto** do formulário multipart/form-data
*
* Detalhes sobre como upload de imagens funciona:
* [https://gitlab.fslab.dev/projeto-bncc/social-network#upload-de-imagens](https://gitlab.fslab.dev/projeto-bncc/social-network#upload-de-imagens)
*
* tags: [Imagens]
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* multipart/form-data:
* schema:
* type: object
* properties:
* tipo:
* type: string
* description: Onde a imagem será usada, um de perfil, capa, comentario
* example: "comentario"
* foto:
* type: string
* format: binary
* responses:
* 200:
* description: Enviou a imagem
* content:
* application/json:
* schema:
* type: object
* properties:
* url:
* type: string
* description: URL que deve ser usado para acessar a imagem que foi enviada
*/
router.get("/img/:id/:img", wrapException(getImagem));
router.get("/img/:id/:img", wrapException(FotoController.getImagem));
router.post("/img", AuthMiddleware, wrapException(upload.single("foto")), wrapException(FotoController.enviarImagem));
export default router;
\ No newline at end of file
......@@ -2,7 +2,6 @@ import express from "express";
import UsuarioControler from "../controllers/UsuarioController.js";
import AuthMiddleware from "../middlewares/AuthMiddleware.js";
import { wrapException } from "./common.js";
import { upload } from "../utils/foto.js";
const router = express.Router();
......@@ -196,6 +195,12 @@ const router = express.Router();
* type: string
* description: Biografia
* example: "Oi eu sou o João"
* fotoPerfil:
* type: string
* description: Foto de perfil
* fotoCapa:
* type: string
* description: Foto de capa
* responses:
* 200:
* description: Usuário atualizado com sucesso
......@@ -281,74 +286,6 @@ const router = express.Router();
* description: Erro interno
*/
/**
* @swagger
* /usuarios/foto-perfil:
* post:
* summary: Atualiza a foto de perfil
* description: |
* Para enviar a imagem deve ela deve ser enviada por meio de formulário multipart/form-data
* com o nome **foto_perfil**
*
* Detalhes sobre como upload de imagens funciona:
* [https://github.com/erickweil/social-network#upload-de-imagens](https://github.com/erickweil/social-network#upload-de-imagens)
* tags: [Imagens]
* security:
* - bearerAuth: []
* requestBody:
* content:
* multipart/form-data:
* schema:
* type: object
* properties:
* foto_perfil:
* type: string
* format: binary
* responses:
* 200:
* description: Imagem de perfil atualizada com sucesso
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Usuario'
* 500:
* description: Erro interno
*/
/**
* @swagger
* /usuarios/foto-capa:
* post:
* summary: Atualiza a foto de capa
* description: |
* Esta rota atualiza a foto de capa do **seu próprio usuário**.
* O a imagem deve ser enviada por meio de formulário multipart/form-data com o nome **foto_capa**
*
* Detalhes sobre como upload de imagens funciona:
* [https://github.com/erickweil/social-network#upload-de-imagens](https://github.com/erickweil/social-network#upload-de-imagens)
* tags: [Imagens]
* security:
* - bearerAuth: []
* requestBody:
* content:
* multipart/form-data:
* schema:
* type: object
* properties:
* foto_capa:
* type: string
* format: binary
* responses:
* 200:
* description: Imagem de capa atualizada com sucesso
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Usuario'
* 500:
* description: Erro interno
*/
router.get("/usuarios", AuthMiddleware, wrapException(UsuarioControler.listarUsuarios));
router.get("/usuarios/logado", AuthMiddleware, wrapException(UsuarioControler.listarUsuarioLogado));
router.get("/usuarios/:id", AuthMiddleware, wrapException(UsuarioControler.listarUsuarioPorId));
......@@ -356,8 +293,6 @@ router.get("/usuarios/:id", AuthMiddleware, wrapException(UsuarioControler.lista
// Operações no próprio usuário autenticado
router.patch("/usuarios", AuthMiddleware, wrapException(UsuarioControler.atualizarUsuario));
router.delete("/usuarios", AuthMiddleware, wrapException(UsuarioControler.deletarUsuario));
router.post("/usuarios/foto-perfil", AuthMiddleware, wrapException(upload.single("foto_perfil")), wrapException(UsuarioControler.atualizarFotoPerfil));
router.post("/usuarios/foto-capa", AuthMiddleware, wrapException(upload.single("foto_capa")), wrapException(UsuarioControler.atualizarFotoCapa));
// Cadastro de usuário não exige autenticação
router.post("/usuarios", wrapException(UsuarioControler.cadastrarUsuario));
......
import { randomUUID } from "crypto";
import { unlink, mkdir, writeFile, rename, readFile } from "fs/promises";
import formidable from "formidable";
import sharp from "sharp";
import mongoose from "mongoose";
import path from "path";
const acceptableFormats = {
export const acceptableFormats = {
"image/gif": ".gif",
"image/jpeg": ".jpg",
"image/jpg": ".jpg",
......@@ -14,9 +11,14 @@ const acceptableFormats = {
"image/webp": ".webp"
};
const fotoPathRegex = /^\/img\/([a-fA-F0-9]{24})\/[a-fA-F0-9\-]+\.jpg$/;
const fotoDir = process.env.IMG_PATH || "."; // para ter as imagens em outro caminho além de /img
const tmpFotoDir = fotoDir+"/tmpimg"; // Onde as imagens são salvas temporariamente
export const fotoPathRegex = /^\/img\/([a-fA-F0-9]{24})\/[a-fA-F0-9\-]+\.jpg$/;
export const fotoDir = process.env.IMG_PATH || "."; // para ter as imagens em outro caminho além de /img
export const tmpFotoDir = fotoDir+"/tmpimg"; // Onde as imagens são salvas temporariamente
if(fotoDir != ".")
await mkdir(fotoDir,{recursive: true});
if(tmpFotoDir != ".")
await mkdir(tmpFotoDir,{recursive: true});
async function copiarSanitizandoImagem(filepath,outfilepath,opcoes) {
const opcoesPadrao = {
......@@ -123,16 +125,28 @@ export const salvarFotoUsuario = async (imgFile, idUsuario, opcoes) => {
}
};
// Verifica se o caminho passado é um caminho válido e está dentro da pasta do usuário
export const checarImagem = async (idUsuario, filepath) => {
if(!filepath) {return false;} // não há arquivo
// precisa converter de ObjectId para string
const strIdUsuario = idUsuario.toString();
// Verificar se o caminho é uma foto do usuário informado
// Não é possível deletar algo que não é do usuário
const matches = filepath.match(fotoPathRegex);
if(!matches || !matches[1] || strIdUsuario !== matches[1]) {
return false;
}
return true;
};
// Não produz erro, apenas retorna verdadeiro se tiver deletado
export const deletarFotoUsuario = async (idUsuario,filepath) => {
if(!filepath) {return false;} // não há arquivo a ser deletado
try{
// precisa converter de ObjectId para string
const strIdUsuario = idUsuario.toString();
// Verificar se o caminho é uma foto de perfil/capa do usuário informado
// Não é possível deletar algo que não é do usuário
const matches = filepath.match(fotoPathRegex);
if(!matches || !matches[1] || strIdUsuario !== matches[1]) {
if(!checarImagem(idUsuario,filepath)) {
console.log("Usuário não tem permissão para deletar: "+filepath);
return false;
......@@ -145,80 +159,4 @@ export const deletarFotoUsuario = async (idUsuario,filepath) => {
console.log(e);
return false;
}
};
export const getImagem = async (req, res) => {
const idUsuario = req.params.id;
const filename = req.params.img;
const filepath = "/img/"+idUsuario+"/"+filename;
const matches = filepath.match(fotoPathRegex);
if(!matches || !matches[1]) {
return res.status(400).json({ error: true, message: "Caminho inválido" });
}
if(mongoose.Types.ObjectId.isValid(idUsuario) === false)
return res.status(400).json({ error: true, message: "ID inválido" });
return res.status(200)
.sendFile(fotoDir+filepath,{
root: path.resolve()
});
};
// NÃO UTILIZAR MULTER
// https://picnature.de/how-to-upload-files-in-nodejs-using-multer-2-0/
// https://github.com/expressjs/multer/tree/v2.0.0-rc.4
//export const upload = multer({
//limits: {
// fileSize: "8MB"
//}
//});
// Usando formidable para upload de arquivos
// https://github.com/node-formidable/formidable
if(fotoDir != ".")
await mkdir(fotoDir,{recursive: true});
if(tmpFotoDir != ".")
await mkdir(tmpFotoDir,{recursive: true});
const filterMimeType = ({name, originalFilename, mimetype}) => {
if(!mimetype || !acceptableFormats[mimetype]) {
//form.emit("error", new formidableErrors.default("invalid type", 0, 400)); // optional make form.parse error
return false;
}
return true;
};
const defaultFormidableOptions = {
maxFiles: 8,
maxFileSize: 8 * 1024 * 1024, // 8MB
uploadDir: tmpFotoDir,
filter: filterMimeType
};
export const upload = {
single: (key) => async (req,res,next) => {
const form = formidable({...defaultFormidableOptions,...{maxFiles: 1}});
const [fields, files] = await form.parse(req);
// Verificar se key está em files
if(files && files[key] !== undefined && files[key].length === 1) {
req.file = files[key][0];
}
req.fields = fields;
next();
},
multiple: (key) => async (req,res,next) => {
const form = formidable(defaultFormidableOptions);
const [fields, files] = await form.parse(req);
// Verificar se key está em files
if(files && files[key] !== undefined && files[key].length > 0) {
req.files = files[key];
}
req.fields = fields;
//console.log(req.files);
//console.log(req.fields);
next();
}
};
\ No newline at end of file
......@@ -17,10 +17,7 @@ describe("Teste Login", () => {
{method:"get", path:"/usuarios/logado"},
{method:"patch", path:"/usuarios"},
{method:"delete", path:"/usuarios"},
{method:"post", path:"/usuarios/foto-perfil"},
{method:"post", path:"/usuarios/foto-capa"},
{method:"get", path:"/usuarios/curtidas"},
{method:"post", path:"/img"}
];
for(let rota of rotas) {
......
......@@ -3,7 +3,7 @@ import {jest,describe,expect,test} from "@jest/globals";
import app from "../../src/app.js";
import request from "supertest";
import { checarArquivoExiste, checarArquivoNaoExiste, deletarUsuario, getUsuarioPorID, postCriarUsuario, postLogin, wrapExpectError } from "../common.js";
import { stat } from "fs/promises";
import { stat, unlink } from "fs/promises";
describe("Usuarios",() => {
let token = false;
......@@ -18,12 +18,13 @@ describe("Usuarios",() => {
expect(token).toBeTruthy();
});
const checarUploadCorreto = async (foto,fotoAnterior,perfil) => {
const checarUploadCorreto = async (foto,fotoAnterior,tipo) => {
const filename = foto.path.substring(foto.path.lastIndexOf("/")+1);
const res = await req
.post(perfil ? "/usuarios/foto-perfil" : "/usuarios/foto-capa")
.post("/img")
.set("Authorization", `Bearer ${token}`)
.attach(perfil ? "foto_perfil" : "foto_capa", foto.path, {
.field("tipo", tipo)
.attach("foto", foto.path, {
filename: filename ,
contentType: foto.mimeType
})
......@@ -31,15 +32,13 @@ describe("Usuarios",() => {
if(foto.expectCode == 200) {
// Se espera sucesso, verificar imagem criada e anterior deletada
expect(res.body.fotoPerfil).toBeTruthy();
const fotoPerfil = res.body.fotoPerfil;
expect(res.body.url).toBeTruthy();
const fotoPerfil = res.body.url;
expect(await checarArquivoExiste("."+fotoPerfil)).toBeTruthy();
// Verificar se foto anterior foi deletada
if(fotoAnterior) {
expect(await checarArquivoNaoExiste("."+fotoAnterior)).toBeTruthy();
}
// deletar foto (para não encher de foto por ai)
await unlink("."+fotoPerfil);
return fotoPerfil;
} else {
......@@ -65,33 +64,20 @@ describe("Usuarios",() => {
{path:"./test/assets/usuario.bmp", mimeType: "image/bmp", expectCode: 400},
{path:"./test/assets/usuario.tiff", mimeType: "image/tiff", expectCode: 400},
{path:"./test/assets/usuario.avif", mimeType: "image/avif", expectCode: 400},
{path:"./test/assets/texto_salvo_errado.jpg", mimeType: "image/jpeg", expectCode: 500},
{path:"./test/assets/corrompido.png", mimeType: "image/png", expectCode: 500},
//{path:"./test/assets/texto_salvo_errado.jpg", mimeType: "image/jpeg", expectCode: 500},
//{path:"./test/assets/corrompido.png", mimeType: "image/png", expectCode: 500},
];
let fotoPerfilAnterior = false;
let fotoCapaAnterior = false;
for(const foto of fotos) {
status.msg = "Testando foto do perfil '"+foto.path+"'";
fotoPerfilAnterior = await checarUploadCorreto(foto,fotoPerfilAnterior,true);
status.msg = "Testando foto da capa '"+foto.path+"'";
fotoCapaAnterior = await checarUploadCorreto(foto,fotoCapaAnterior,false);
fotoPerfilAnterior = await checarUploadCorreto(foto,fotoPerfilAnterior,"perfil");
//status.msg = "Testando foto da capa '"+foto.path+"'";
//fotoCapaAnterior = await checarUploadCorreto(foto,fotoCapaAnterior,"capa");
}
}), 120000);
test("Não deve ser possível atualizar foto via patch /usuarios", async () => {
const res = await req
.patch("/usuarios")
.send({
fotoCapa: "/img/"+usuarioAutenticado._id+"/4d095a193e563ded.gif"
})
.set("Authorization", `Bearer ${token}`)
.set("Accept", "aplication/json")
.expect(400);
expect(res.body.error).toBeTruthy();
});
// O que falta:
// Testar imagens com tamanho muito grande (> 8MB)
// Testar enviar mensagem escondida via imagem
......
......@@ -145,7 +145,6 @@ describe("Usuarios",() => {
test("Atualizar Usuário", async () => {
const nome = "!"+novoUsuario.nome;
const username = "mod_"+novoUsuario.username;
const biografia = "Teste de Biografia";
const preferencias = {
notificacao: false,
......@@ -156,7 +155,6 @@ describe("Usuarios",() => {
.patch("/usuarios")
.send({
nome: nome,
username: username,
biografia: biografia,
preferencias: preferencias
})
......@@ -165,7 +163,6 @@ describe("Usuarios",() => {
.expect(200);
expect(res.body.nome).toBe(nome);
expect(res.body.username).toBe(username);
expect(res.body.biografia).toBe(biografia);
//expect(res.body.email).toBeUndefined(); // exibirEmail false deve não ter email aqui
});
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment