diff --git a/api/routes.js b/api/routes.js index 8d550c7..2f95e45 100644 --- a/api/routes.js +++ b/api/routes.js @@ -4,6 +4,7 @@ const rateLimit = require("express-rate-limit"); const axios = require("axios"); const { CaptchaSecretKey } = require("../config"); +const { Style } = require("../models/Style"); // Middleware const router = express.Router(); @@ -12,6 +13,11 @@ const cache = apicache.middleware; const onlyStatus200 = (req, res) => res.statusCode === 200; const cacheSuccessful = cache("10 minutes", onlyStatus200); +const clearCache = (req, res, next) => { + apicache.clear(); + next(); +}; + // Allow 1 request per 10 minutes const GHRateLimiter = rateLimit({ windowMs: 10 * 60 * 1000, @@ -35,16 +41,25 @@ const recaptcha = (req, res, next) => { }); }; -function isAdmin(req, res, next) { +const isAuthorized = async (req, res, next) => { + const { url } = req.body; + if (!url) return res.status(400).json({ error: "Request must contain url field" }); + const existingStyle = await Style.findOne({ url }).lean(); + if (!existingStyle) return res.status(404).json({ error: "Style does not exist" }); + req.styleData = existingStyle; + if (process.env.NODE_ENV !== "production") return next(); + if (!req.isAuthenticated()) { return res.status(401).json({ error: "Authentication is required to perform this action" }); } - if (req.user.role !== "Admin") { + const isAdmin = req.user.role === "Admin"; + const isOwner = req.user.username === existingStyle.owner; + if (!isAdmin && !isOwner) { return res.status(403).json({ error: "You are not authorized to perform this action" }); } next(); -} +}; const { getStyles, @@ -53,6 +68,7 @@ const { addStyle, updateAllStyles, updateStyle, + editStyle, deleteStyle, getStylesByOwner } = require("./styles"); @@ -67,8 +83,9 @@ router.get("/search/:page?", searchStyle); router.get("/owner/:owner/:page?", cacheSuccessful, getStylesByOwner); router.post("/style/add", recaptcha, addStyle); router.put("/style/update/all", GHRateLimiter, updateAllStyles); -router.put("/style/update/:id", GHRateLimiter, updateStyle); -router.delete("/style/delete", isAdmin, deleteStyle); +router.put("/style/update", GHRateLimiter, updateStyle); +router.put("/style/edit", isAuthorized, clearCache, editStyle); +router.delete("/style/delete", isAuthorized, clearCache, deleteStyle); router.get("/me", getCurrentUser); diff --git a/api/styles.js b/api/styles.js index c8eb3e6..28636cf 100644 --- a/api/styles.js +++ b/api/styles.js @@ -149,10 +149,10 @@ function addStyle(req, res) { } function updateStyle(req, res) { - const styleId = req.params.id; - if (!styleId) return res.status(400).json({ error: "Request must contain styleId field" }); + const { url } = req.body; + if (!url) return res.status(400).json({ error: "Request must contain url field" }); - Style.findById(styleId, async (error, style) => { + Style.findOne({ url }, async (error, style) => { if (error) return res.status(500).json({ error }); if (!style) return res.status(404).json({ error: "Style was not found in our base" }); @@ -163,8 +163,8 @@ function updateStyle(req, res) { if (data.isArchived) return res.status(400).json({ error: "Repository is archived" }); if (data.isFork) return res.status(400).json({ error: "Repository is forked" }); - Style.findByIdAndUpdate( - styleId, + Style.findOneAndUpdate( + { url }, data, { new: true }, (updateError, newStyle) => { @@ -193,12 +193,41 @@ function updateAllStyles(req, res) { }); } -async function deleteStyle(req, res) { - const { url } = req.body; - const existingStyle = await Style.findOne({ url }).lean(); - if (!existingStyle) return res.status(404).json({ error: "Style does not exist" }); +function editStyle(req, res) { + const { url, ...customData } = req.body; + if (!customData.customName && !customData.customPreview) { + return res.status(400).json({ error: "Request must contain customName or customPreview fields" }); + } + + if (customData.customPreview) { + try { + const previewURL = new URL(customData.customPreview); + if (!previewURL.protocol.includes("https:")) { + return res.status(400).json({ error: "Preview must be from a secure source" }); + } + } catch (error) { + return res.status(400).json({ error: "Invalid preview URL" }); + } + } + + // Remove non-custom fields + Object.keys(customData).forEach(key => { + const fieldToDelete = !["customName", "customPreview"].includes(key); + return fieldToDelete && delete customData[key]; + }); - Style.findOneAndDelete({ url }, (error, style) => { + Style.findOneAndUpdate( + { url: req.styleData.url }, + { $set: customData }, + { new: true }, + (error, style) => { + if (error) return res.status(500).json({ error }); + return res.status(200).json({ style }); + }); +} + +async function deleteStyle(req, res) { + Style.findOneAndDelete({ url: req.styleData.url }, (error, style) => { if (error) return res.status(500).json({ error }); return res.status(200).json({ style }); }); @@ -230,6 +259,7 @@ module.exports = { addStyle, updateStyle, updateAllStyles, + editStyle, deleteStyle, getStylesByOwner }; diff --git a/common.js b/common.js index 054fc01..58ce3ee 100644 --- a/common.js +++ b/common.js @@ -92,9 +92,7 @@ function addExpressMiddleware(app) { "img-src": [ "'self'", "data:", - "https://raw.githubusercontent.com", - "https://github.githubassets.com", - "https://www.google-analytics.com" + "https:" ], "style-src": ["'self'", "'unsafe-inline'"], "script-src": [ diff --git a/models/Style.js b/models/Style.js index 9b787ba..bca8db6 100644 --- a/models/Style.js +++ b/models/Style.js @@ -24,9 +24,11 @@ const schema = new Schema({ isPrivate: Boolean, isArchived: Boolean, isFork: Boolean, + customName: String, + customPreview: String }); -schema.index({ name: "text", owner: "text" }); +schema.index({ name: "text", customName: "text", owner: "text" }); schema.plugin(mongoosePaginate); exports.Style = mongoose.model("Style", schema); diff --git a/server.js b/server.js index e75cbbf..5380123 100644 --- a/server.js +++ b/server.js @@ -18,7 +18,7 @@ addExpressMiddleware(app); app.use(express.static("public")); app.use("/api", api); -app.get("/login", passport.authenticate("github", { scope: ["user:email"] })); +app.get("/login", passport.authenticate("github", { scope: ["read:user"] })); app.get("/github/callback", passport.authenticate("github"), (req, res) => res.redirect("/")); const clientIndex = path.join(__dirname, "public/index.html"); diff --git a/src/src/App.vue b/src/src/App.vue index 6975d2a..9af5c99 100644 --- a/src/src/App.vue +++ b/src/src/App.vue @@ -1,7 +1,7 @@