From 9d682aea1cebf25ccd8fd2f64d4e7e53d2910f16 Mon Sep 17 00:00:00 2001 From: Faithy4444 <161722786+Faithy4444@users.noreply.github.com> Date: Sat, 24 Jan 2026 00:53:58 +0200 Subject: [PATCH 01/10] hashtag link fixed --- backend/data/blooms.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/data/blooms.py b/backend/data/blooms.py index 7e280cf..db3c369 100644 --- a/backend/data/blooms.py +++ b/backend/data/blooms.py @@ -16,7 +16,11 @@ class Bloom: def add_bloom(*, sender: User, content: str) -> Bloom: - hashtags = [word[1:] for word in content.split(" ") if word.startswith("#")] + + if len(content) > 280: + raise ValueError("Bloom content cannot exceed 280 characters") + + hashtags = re.findall(r'#(\w+)', content) now = datetime.datetime.now(tz=datetime.UTC) bloom_id = int(now.timestamp() * 1000000) From 99677a8e930f6cad7b44beb025da7f62d6778c70 Mon Sep 17 00:00:00 2001 From: Faithy4444 <161722786+Faithy4444@users.noreply.github.com> Date: Sun, 25 Jan 2026 23:34:48 +0200 Subject: [PATCH 02/10] changed to only fetch hashtags when the hashtag changes --- front-end/index.html | 6 +++--- front-end/views/hashtag.mjs | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/front-end/index.html b/front-end/index.html index 89d6b13..b1793d4 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -4,14 +4,14 @@ Purple Forest - +

Purple Forest Share a Bloom

Please enable JavaScript in your browser.

- + diff --git a/front-end/views/hashtag.mjs b/front-end/views/hashtag.mjs index 7b7e996..45293de 100644 --- a/front-end/views/hashtag.mjs +++ b/front-end/views/hashtag.mjs @@ -17,7 +17,11 @@ import {createHeading} from "../components/heading.mjs"; function hashtagView(hashtag) { destroy(); - apiService.getBloomsByHashtag(hashtag); +// changed to only fetch hashtags when hashtag changes + if (state.currentHashtag !== hashtag) { + state.currentHashtag = hashtag; + apiService.getBloomsByHashtag(hashtag); + } renderOne( state.isLoggedIn, From 3e648c1512b3205b28cc8353de2edd4252f55f26 Mon Sep 17 00:00:00 2001 From: Faithy4444 <161722786+Faithy4444@users.noreply.github.com> Date: Sun, 25 Jan 2026 23:55:44 +0200 Subject: [PATCH 03/10] extra rendering logic removed --- front-end/views/hashtag.mjs | 29 ++++------------------------- 1 file changed, 4 insertions(+), 25 deletions(-) diff --git a/front-end/views/hashtag.mjs b/front-end/views/hashtag.mjs index 45293de..86bb048 100644 --- a/front-end/views/hashtag.mjs +++ b/front-end/views/hashtag.mjs @@ -1,46 +1,25 @@ -import {renderOne, renderEach, destroy} from "../lib/render.mjs"; +import {renderOne, renderEach,} from "../lib/render.mjs"; import { state, apiService, - getLogoutContainer, - getLoginContainer, getTimelineContainer, getHeadingContainer, } from "../index.mjs"; -import {createLogin, handleLogin} from "../components/login.mjs"; -import {createLogout, handleLogout} from "../components/logout.mjs"; import {createBloom} from "../components/bloom.mjs"; import {createHeading} from "../components/heading.mjs"; // Hashtag view: show all tweets containing this tag function hashtagView(hashtag) { - destroy(); -// changed to only fetch hashtags when hashtag changes + // changed to only fetch hashtags when hashtag changes if (state.currentHashtag !== hashtag) { state.currentHashtag = hashtag; + state.isLoadingHashtag = true; apiService.getBloomsByHashtag(hashtag); } - renderOne( - state.isLoggedIn, - getLogoutContainer(), - "logout-template", - createLogout - ); - document - .querySelector("[data-action='logout']") - ?.addEventListener("click", handleLogout); - renderOne( - state.isLoggedIn, - getLoginContainer(), - "login-template", - createLogin - ); - document - .querySelector("[data-action='login']") - ?.addEventListener("click", handleLogin); + renderOne( state.currentHashtag, From 0ee9a714a9ad03bdae4c6a35b30e132335ada975 Mon Sep 17 00:00:00 2001 From: Faithy4444 <161722786+Faithy4444@users.noreply.github.com> Date: Mon, 26 Jan 2026 00:14:20 +0200 Subject: [PATCH 04/10] html changed --- front-end/index.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/front-end/index.html b/front-end/index.html index 89d6b13..b1793d4 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -4,14 +4,14 @@ Purple Forest - +

Purple Forest Share a Bloom

Please enable JavaScript in your browser.

- + From c89553d1db67ab6c90ef3cc5c6ad376e2502acd7 Mon Sep 17 00:00:00 2001 From: Faithy4444 <161722786+Faithy4444@users.noreply.github.com> Date: Mon, 26 Jan 2026 00:26:03 +0200 Subject: [PATCH 05/10] migrations added --- .../1769379813960_add-rebloom-table.js | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 db/migrations/1769379813960_add-rebloom-table.js diff --git a/db/migrations/1769379813960_add-rebloom-table.js b/db/migrations/1769379813960_add-rebloom-table.js new file mode 100644 index 0000000..9d10986 --- /dev/null +++ b/db/migrations/1769379813960_add-rebloom-table.js @@ -0,0 +1,32 @@ +/** + * @type {import('node-pg-migrate').ColumnDefinitions | undefined} + */ +export const shorthands = undefined; + +/** + * @param pgm {import('node-pg-migrate').MigrationBuilder} + * @param run {() => void | undefined} + * @returns {Promise | void} + */ +export const up = pgm => { + pgm.createTable('reblooms', { + id: 'id', + bloom_id: { type: 'bigint', notNull: true, references: 'blooms(id)' }, + user_id: { type: 'int', notNull: true, references: 'users(id)' }, + rebloom_timestamp: { type: 'timestamp', notNull: true, default: pgm.func('NOW()') }, + }); + + pgm.addConstraint('reblooms', 'unique_user_bloom', { + unique: ['bloom_id', 'user_id'] + }); +}; + +/** + * @param pgm {import('node-pg-migrate').MigrationBuilder} + * @param run {() => void | undefined} + * @returns {Promise | void} + */ + +export const down = pgm => { + pgm.dropTable('reblooms'); +}; From 7579335d89df3c2926fa0ab9f2e7e0908c28d62c Mon Sep 17 00:00:00 2001 From: Faithy4444 <161722786+Faithy4444@users.noreply.github.com> Date: Mon, 26 Jan 2026 00:36:48 +0200 Subject: [PATCH 06/10] add rebloom backend done --- backend/data/blooms.py | 25 +++++++++++++++++++++++++ db/.env | 7 +++++++ 2 files changed, 32 insertions(+) create mode 100644 db/.env diff --git a/backend/data/blooms.py b/backend/data/blooms.py index 7e280cf..c3f295b 100644 --- a/backend/data/blooms.py +++ b/backend/data/blooms.py @@ -140,3 +140,28 @@ def make_limit_clause(limit: Optional[int], kwargs: Dict[Any, Any]) -> str: else: limit_clause = "" return limit_clause + +#rebloom function + +def add_rebloom(*, user: User, bloom_id: int) -> None: + """Adds a rebloom for a user.""" + with db_cursor() as cur: + cur.execute( + """ + INSERT INTO reblooms (user_id, bloom_id) + VALUES (%(user_id)s, %(bloom_id)s) + ON CONFLICT (user_id, bloom_id) DO NOTHING + """, + dict(user_id=user.id, bloom_id=bloom_id), + ) + + +def get_rebloom_count(bloom_id: int) -> int: + """Returns the number of times a bloom has been rebloomed.""" + with db_cursor() as cur: + cur.execute( + "SELECT COUNT(*) FROM reblooms WHERE bloom_id = %s", + (bloom_id,), + ) + count = cur.fetchone()[0] + return count \ No newline at end of file diff --git a/db/.env b/db/.env new file mode 100644 index 0000000..ff8d59b --- /dev/null +++ b/db/.env @@ -0,0 +1,7 @@ +DATBASE_URL=postgres://postgres:purple123!@localhost:5432/purpleforest +JWT_SECRET_KEY=supersecretkey123 +POSTGRES_USER=postgres +POSTGRES_PASSWORD=purple123! +POSTGRES_DB=purpleforest +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 From f36d728a05f9ce9f14ff9c12f510bc500837555b Mon Sep 17 00:00:00 2001 From: Faithy4444 <161722786+Faithy4444@users.noreply.github.com> Date: Mon, 26 Jan 2026 00:46:30 +0200 Subject: [PATCH 07/10] api call --- backend/endpoints.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/backend/endpoints.py b/backend/endpoints.py index 0e177a0..77dfa1d 100644 --- a/backend/endpoints.py +++ b/backend/endpoints.py @@ -177,6 +177,26 @@ def get_bloom(id_str): return make_response((f"Bloom not found", 404)) return jsonify(bloom) +@jwt_required() +def rebloom(): + type_check_error = verify_request_fields({"bloom_id": int}) + if type_check_error is not None: + return type_check_error + + current_user = get_current_user() + bloom_id = request.json["bloom_id"] + + # Add the rebloom + blooms.add_rebloom(user=current_user, bloom_id=bloom_id) + + #return new rebloom count + count = blooms.get_rebloom_count(bloom_id) + + return jsonify({ + "success": True, + "rebloom_count": count + }) + @jwt_required() def home_timeline(): From 0466be6b92803148983423f81d1d9864c9b6cb47 Mon Sep 17 00:00:00 2001 From: Faithy4444 <161722786+Faithy4444@users.noreply.github.com> Date: Mon, 26 Jan 2026 00:56:09 +0200 Subject: [PATCH 08/10] added rebloom to apiServices --- front-end/lib/api.mjs | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/front-end/lib/api.mjs b/front-end/lib/api.mjs index f4b5339..239b8ab 100644 --- a/front-end/lib/api.mjs +++ b/front-end/lib/api.mjs @@ -212,6 +212,45 @@ async function postBloom(content) { } } +async function rebloomBloom(bloomId) { + try { + const data = await _apiRequest("/reblooms", { + method: "POST", + body: JSON.stringify({ bloom_id: bloomId }), + }); + + if (data.success) { + // Update the timeline to reflect the new rebloom count + // Update the specific bloom in timelineBlooms + const timelineBlooms = state.timelineBlooms.map(b => { + if (b.id === bloomId) { + return { ...b, rebloom_count: data.rebloom_count || 1 }; + } + return b; + }); + state.updateState({ timelineBlooms }); + + // Also update in current profile if needed + const profiles = state.profiles.map(profile => { + if (profile.blooms) { + profile.blooms = profile.blooms.map(b => { + if (b.id === bloomId) { + return { ...b, rebloom_count: data.rebloom_count || 1 }; + } + return b; + }); + } + return profile; + }); + state.updateState({ profiles }); + } + + return data; + } catch (error) { + return { success: false }; + } +} + // ======= USER methods async function getProfile(username) { const endpoint = username ? `/profile/${username}` : "/profile"; @@ -292,6 +331,7 @@ const apiService = { getBlooms, postBloom, getBloomsByHashtag, + rebloomBloom, // User methods getProfile, From b69ffd2d6936dbca4fedd57af5e8099bcfaec6f6 Mon Sep 17 00:00:00 2001 From: Faithy4444 <161722786+Faithy4444@users.noreply.github.com> Date: Tue, 27 Jan 2026 01:14:31 +0200 Subject: [PATCH 09/10] rebloom now works but vanish after logout --- backend/endpoints.py | 39 ++- backend/main.py | 6 +- backend/node_modules/.package-lock.json | 42 +++ backend/node_modules/cors/LICENSE | 22 ++ backend/node_modules/cors/README.md | 277 ++++++++++++++++++ backend/node_modules/cors/lib/index.js | 238 +++++++++++++++ backend/node_modules/cors/package.json | 42 +++ backend/node_modules/object-assign/index.js | 90 ++++++ backend/node_modules/object-assign/license | 21 ++ .../node_modules/object-assign/package.json | 42 +++ backend/node_modules/vary/HISTORY.md | 39 +++ backend/node_modules/vary/LICENSE | 22 ++ backend/node_modules/vary/index.js | 149 ++++++++++ backend/node_modules/vary/package.json | 43 +++ db/.env | 1 - front-end/components/bloom.mjs | 8 +- front-end/index.html | 16 +- front-end/index.mjs | 23 ++ 18 files changed, 1093 insertions(+), 27 deletions(-) create mode 100644 backend/node_modules/.package-lock.json create mode 100644 backend/node_modules/cors/LICENSE create mode 100644 backend/node_modules/cors/README.md create mode 100644 backend/node_modules/cors/lib/index.js create mode 100644 backend/node_modules/cors/package.json create mode 100644 backend/node_modules/object-assign/index.js create mode 100644 backend/node_modules/object-assign/license create mode 100644 backend/node_modules/object-assign/package.json create mode 100644 backend/node_modules/vary/HISTORY.md create mode 100644 backend/node_modules/vary/LICENSE create mode 100644 backend/node_modules/vary/index.js create mode 100644 backend/node_modules/vary/package.json diff --git a/backend/endpoints.py b/backend/endpoints.py index 77dfa1d..5b80b30 100644 --- a/backend/endpoints.py +++ b/backend/endpoints.py @@ -201,37 +201,32 @@ def rebloom(): @jwt_required() def home_timeline(): current_user = get_current_user() - - # Get blooms from followed users + + # 1. Get reblooms + user_reblooms = blooms.get_user_reblooms(current_user.username, limit=50) + + # 2. Get blooms from followed users followed_users = get_followed_usernames(current_user) nested_user_blooms = [ blooms.get_blooms_for_user(followed_user, limit=50) for followed_user in followed_users ] - - # Flatten list of blooms from followed users - followed_blooms = [bloom for blooms in nested_user_blooms for bloom in blooms] - - # Get the current user's own blooms + + # 3. Flatten followed blooms + followed_blooms = [bloom for blooms_list in nested_user_blooms for bloom in blooms_list] + + # 4. Get original blooms own_blooms = blooms.get_blooms_for_user(current_user.username, limit=50) - - # Combine own blooms with followed blooms - all_blooms = followed_blooms + own_blooms - - # Sort by timestamp (newest first) - sorted_blooms = list( - sorted(all_blooms, key=lambda bloom: bloom.sent_timestamp, reverse=True) - ) - + + # 5. COMBINE: followed + own + reblooms + all_blooms = followed_blooms + own_blooms + user_reblooms + + # 6. Sort newest first + sorted_blooms = sorted(all_blooms, key=lambda b: b['sent_timestamp'], reverse=True) + return jsonify(sorted_blooms) -def user_blooms(profile_username): - user_blooms = blooms.get_blooms_for_user(profile_username) - user_blooms.reverse() - return jsonify(user_blooms) - - @jwt_required() def suggested_follows(limit_str): try: diff --git a/backend/main.py b/backend/main.py index 7ba155f..2a5fc46 100644 --- a/backend/main.py +++ b/backend/main.py @@ -9,11 +9,11 @@ home_timeline, login, other_profile, + rebloom, register, self_profile, send_bloom, suggested_follows, - user_blooms, ) from dotenv import load_dotenv @@ -58,8 +58,10 @@ def main(): app.add_url_rule("/bloom", methods=["POST"], view_func=send_bloom) app.add_url_rule("/bloom/", methods=["GET"], view_func=get_bloom) - app.add_url_rule("/blooms/", view_func=user_blooms) app.add_url_rule("/hashtag/", view_func=hashtag) + + app.add_url_rule("/reblooms", methods=["POST"], view_func=rebloom) + app.run(host="0.0.0.0", port="3000", debug=True) diff --git a/backend/node_modules/.package-lock.json b/backend/node_modules/.package-lock.json new file mode 100644 index 0000000..2b66577 --- /dev/null +++ b/backend/node_modules/.package-lock.json @@ -0,0 +1,42 @@ +{ + "name": "backend", + "lockfileVersion": 3, + "requires": true, + "packages": { + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/backend/node_modules/cors/LICENSE b/backend/node_modules/cors/LICENSE new file mode 100644 index 0000000..fd10c84 --- /dev/null +++ b/backend/node_modules/cors/LICENSE @@ -0,0 +1,22 @@ +(The MIT License) + +Copyright (c) 2013 Troy Goode + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/backend/node_modules/cors/README.md b/backend/node_modules/cors/README.md new file mode 100644 index 0000000..3d206e5 --- /dev/null +++ b/backend/node_modules/cors/README.md @@ -0,0 +1,277 @@ +# cors + +[![NPM Version][npm-image]][npm-url] +[![NPM Downloads][downloads-image]][downloads-url] +[![Build Status][github-actions-ci-image]][github-actions-ci-url] +[![Test Coverage][coveralls-image]][coveralls-url] + +CORS is a [Node.js](https://nodejs.org/en/) middleware for [Express](https://expressjs.com/)/[Connect](https://github.com/senchalabs/connect) that sets [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS) response headers. These headers tell browsers which origins can read responses from your server. + +> [!IMPORTANT] +> **How CORS Works:** This package sets response headers—it doesn't block requests. CORS is enforced by browsers: they check the headers and decide if JavaScript can read the response. Non-browser clients (curl, Postman, other servers) ignore CORS entirely. See the [MDN CORS guide](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS) for details. + +* [Installation](#installation) +* [Usage](#usage) + * [Simple Usage](#simple-usage-enable-all-cors-requests) + * [Enable CORS for a Single Route](#enable-cors-for-a-single-route) + * [Configuring CORS](#configuring-cors) + * [Configuring CORS w/ Dynamic Origin](#configuring-cors-w-dynamic-origin) + * [Enabling CORS Pre-Flight](#enabling-cors-pre-flight) + * [Customizing CORS Settings Dynamically per Request](#customizing-cors-settings-dynamically-per-request) +* [Configuration Options](#configuration-options) +* [Common Misconceptions](#common-misconceptions) +* [License](#license) +* [Original Author](#original-author) + +## Installation + +This is a [Node.js](https://nodejs.org/en/) module available through the +[npm registry](https://www.npmjs.com/). Installation is done using the +[`npm install` command](https://docs.npmjs.com/downloading-and-installing-packages-locally): + +```sh +$ npm install cors +``` + +## Usage + +### Simple Usage (Enable *All* CORS Requests) + +```javascript +var express = require('express') +var cors = require('cors') +var app = express() + +// Adds headers: Access-Control-Allow-Origin: * +app.use(cors()) + +app.get('/products/:id', function (req, res, next) { + res.json({msg: 'Hello'}) +}) + +app.listen(80, function () { + console.log('web server listening on port 80') +}) +``` + +### Enable CORS for a Single Route + +```javascript +var express = require('express') +var cors = require('cors') +var app = express() + +// Adds headers: Access-Control-Allow-Origin: * +app.get('/products/:id', cors(), function (req, res, next) { + res.json({msg: 'Hello'}) +}) + +app.listen(80, function () { + console.log('web server listening on port 80') +}) +``` + +### Configuring CORS + +See the [configuration options](#configuration-options) for details. + +```javascript +var express = require('express') +var cors = require('cors') +var app = express() + +var corsOptions = { + origin: 'http://example.com', + optionsSuccessStatus: 200 // some legacy browsers (IE11, various SmartTVs) choke on 204 +} + +// Adds headers: Access-Control-Allow-Origin: http://example.com, Vary: Origin +app.get('/products/:id', cors(corsOptions), function (req, res, next) { + res.json({msg: 'Hello'}) +}) + +app.listen(80, function () { + console.log('web server listening on port 80') +}) +``` + +### Configuring CORS w/ Dynamic Origin + +This module supports validating the origin dynamically using a function provided +to the `origin` option. This function will be passed a string that is the origin +(or `undefined` if the request has no origin), and a `callback` with the signature +`callback(error, origin)`. + +The `origin` argument to the callback can be any value allowed for the `origin` +option of the middleware, except a function. See the +[configuration options](#configuration-options) section for more information on all +the possible value types. + +This function is designed to allow the dynamic loading of allowed origin(s) from +a backing datasource, like a database. + +```javascript +var express = require('express') +var cors = require('cors') +var app = express() + +var corsOptions = { + origin: function (origin, callback) { + // db.loadOrigins is an example call to load + // a list of origins from a backing database + db.loadOrigins(function (error, origins) { + callback(error, origins) + }) + } +} + +// Adds headers: Access-Control-Allow-Origin: , Vary: Origin +app.get('/products/:id', cors(corsOptions), function (req, res, next) { + res.json({msg: 'Hello'}) +}) + +app.listen(80, function () { + console.log('web server listening on port 80') +}) +``` + +### Enabling CORS Pre-Flight + +Certain CORS requests are considered 'complex' and require an initial +`OPTIONS` request (called the "pre-flight request"). An example of a +'complex' CORS request is one that uses an HTTP verb other than +GET/HEAD/POST (such as DELETE) or that uses custom headers. To enable +pre-flighting, you must add a new OPTIONS handler for the route you want +to support: + +```javascript +var express = require('express') +var cors = require('cors') +var app = express() + +app.options('/products/:id', cors()) // preflight for DELETE +app.del('/products/:id', cors(), function (req, res, next) { + res.json({msg: 'Hello'}) +}) + +app.listen(80, function () { + console.log('web server listening on port 80') +}) +``` + +You can also enable pre-flight across-the-board like so: + +```javascript +app.options('*', cors()) // include before other routes +``` + +NOTE: When using this middleware as an application level middleware (for +example, `app.use(cors())`), pre-flight requests are already handled for all +routes. + +### Customizing CORS Settings Dynamically per Request + +For APIs that require different CORS configurations for specific routes or requests, you can dynamically generate CORS options based on the incoming request. The `cors` middleware allows you to achieve this by passing a function instead of static options. This function is called for each incoming request and must use the callback pattern to return the appropriate CORS options. + +The function accepts: +1. **`req`**: + - The incoming request object. + +2. **`callback(error, corsOptions)`**: + - A function used to return the computed CORS options. + - **Arguments**: + - **`error`**: Pass `null` if there’s no error, or an error object to indicate a failure. + - **`corsOptions`**: An object specifying the CORS policy for the current request. + +Here’s an example that handles both public routes and restricted, credential-sensitive routes: + +```javascript +var dynamicCorsOptions = function(req, callback) { + var corsOptions; + if (req.path.startsWith('/auth/connect/')) { + // Access-Control-Allow-Origin: http://mydomain.com, Access-Control-Allow-Credentials: true, Vary: Origin + corsOptions = { + origin: 'http://mydomain.com', + credentials: true + }; + } else { + // Access-Control-Allow-Origin: * + corsOptions = { origin: '*' }; + } + callback(null, corsOptions); +}; + +app.use(cors(dynamicCorsOptions)); + +app.get('/auth/connect/twitter', function (req, res) { + res.send('Hello'); +}); + +app.get('/public', function (req, res) { + res.send('Hello'); +}); + +app.listen(80, function () { + console.log('web server listening on port 80') +}) +``` + +## Configuration Options + +* `origin`: Configures the **Access-Control-Allow-Origin** CORS header. Possible values: + - `Boolean` - set `origin` to `true` to reflect the [request origin](https://datatracker.ietf.org/doc/html/draft-abarth-origin-09), as defined by `req.header('Origin')`, or set it to `false` to disable CORS. + - `String` - set `origin` to a specific origin. For example, if you set it to + - `"http://example.com"` only requests from "http://example.com" will be allowed. + - `"*"` for all domains to be allowed. + - `RegExp` - set `origin` to a regular expression pattern which will be used to test the request origin. If it's a match, the request origin will be reflected. For example the pattern `/example\.com$/` will reflect any request that is coming from an origin ending with "example.com". + - `Array` - set `origin` to an array of valid origins. Each origin can be a `String` or a `RegExp`. For example `["http://example1.com", /\.example2\.com$/]` will accept any request from "http://example1.com" or from a subdomain of "example2.com". + - `Function` - set `origin` to a function implementing some custom logic. The function takes the request origin as the first parameter and a callback (called as `callback(err, origin)`, where `origin` is a non-function value of the `origin` option) as the second. +* `methods`: Configures the **Access-Control-Allow-Methods** CORS header. Expects a comma-delimited string (ex: 'GET,PUT,POST') or an array (ex: `['GET', 'PUT', 'POST']`). +* `allowedHeaders`: Configures the **Access-Control-Allow-Headers** CORS header. Expects a comma-delimited string (ex: 'Content-Type,Authorization') or an array (ex: `['Content-Type', 'Authorization']`). If not specified, defaults to reflecting the headers specified in the request's **Access-Control-Request-Headers** header. +* `exposedHeaders`: Configures the **Access-Control-Expose-Headers** CORS header. Expects a comma-delimited string (ex: 'Content-Range,X-Content-Range') or an array (ex: `['Content-Range', 'X-Content-Range']`). If not specified, no custom headers are exposed. +* `credentials`: Configures the **Access-Control-Allow-Credentials** CORS header. Set to `true` to pass the header, otherwise it is omitted. +* `maxAge`: Configures the **Access-Control-Max-Age** CORS header. Set to an integer to pass the header, otherwise it is omitted. +* `preflightContinue`: Pass the CORS preflight response to the next handler. +* `optionsSuccessStatus`: Provides a status code to use for successful `OPTIONS` requests, since some legacy browsers (IE11, various SmartTVs) choke on `204`. + +The default configuration is the equivalent of: + +```json +{ + "origin": "*", + "methods": "GET,HEAD,PUT,PATCH,POST,DELETE", + "preflightContinue": false, + "optionsSuccessStatus": 204 +} +``` + +## Common Misconceptions + +### "CORS blocks requests from disallowed origins" + +**No.** Your server receives and processes every request. CORS headers tell the browser whether JavaScript can read the response—not whether the request is allowed. + +### "CORS protects my API from unauthorized access" + +**No.** CORS is not access control. Any HTTP client (curl, Postman, another server) can call your API regardless of CORS settings. Use authentication and authorization to protect your API. + +### "Setting `origin: 'http://example.com'` means only that domain can access my server" + +**No.** It means browsers will only let JavaScript from that origin read responses. The server still responds to all requests. + +## License + +[MIT License](http://www.opensource.org/licenses/mit-license.php) + +## Original Author + +[Troy Goode](https://github.com/TroyGoode) ([troygoode@gmail.com](mailto:troygoode@gmail.com)) + +[coveralls-image]: https://img.shields.io/coveralls/expressjs/cors/master.svg +[coveralls-url]: https://coveralls.io/r/expressjs/cors?branch=master +[downloads-image]: https://img.shields.io/npm/dm/cors.svg +[downloads-url]: https://npmjs.com/package/cors +[github-actions-ci-image]: https://img.shields.io/github/actions/workflow/status/expressjs/cors/ci.yml?branch=master&label=ci +[github-actions-ci-url]: https://github.com/expressjs/cors?query=workflow%3Aci +[npm-image]: https://img.shields.io/npm/v/cors.svg +[npm-url]: https://npmjs.com/package/cors diff --git a/backend/node_modules/cors/lib/index.js b/backend/node_modules/cors/lib/index.js new file mode 100644 index 0000000..ad899ca --- /dev/null +++ b/backend/node_modules/cors/lib/index.js @@ -0,0 +1,238 @@ +(function () { + + 'use strict'; + + var assign = require('object-assign'); + var vary = require('vary'); + + var defaults = { + origin: '*', + methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', + preflightContinue: false, + optionsSuccessStatus: 204 + }; + + function isString(s) { + return typeof s === 'string' || s instanceof String; + } + + function isOriginAllowed(origin, allowedOrigin) { + if (Array.isArray(allowedOrigin)) { + for (var i = 0; i < allowedOrigin.length; ++i) { + if (isOriginAllowed(origin, allowedOrigin[i])) { + return true; + } + } + return false; + } else if (isString(allowedOrigin)) { + return origin === allowedOrigin; + } else if (allowedOrigin instanceof RegExp) { + return allowedOrigin.test(origin); + } else { + return !!allowedOrigin; + } + } + + function configureOrigin(options, req) { + var requestOrigin = req.headers.origin, + headers = [], + isAllowed; + + if (!options.origin || options.origin === '*') { + // allow any origin + headers.push([{ + key: 'Access-Control-Allow-Origin', + value: '*' + }]); + } else if (isString(options.origin)) { + // fixed origin + headers.push([{ + key: 'Access-Control-Allow-Origin', + value: options.origin + }]); + headers.push([{ + key: 'Vary', + value: 'Origin' + }]); + } else { + isAllowed = isOriginAllowed(requestOrigin, options.origin); + // reflect origin + headers.push([{ + key: 'Access-Control-Allow-Origin', + value: isAllowed ? requestOrigin : false + }]); + headers.push([{ + key: 'Vary', + value: 'Origin' + }]); + } + + return headers; + } + + function configureMethods(options) { + var methods = options.methods; + if (methods.join) { + methods = options.methods.join(','); // .methods is an array, so turn it into a string + } + return { + key: 'Access-Control-Allow-Methods', + value: methods + }; + } + + function configureCredentials(options) { + if (options.credentials === true) { + return { + key: 'Access-Control-Allow-Credentials', + value: 'true' + }; + } + return null; + } + + function configureAllowedHeaders(options, req) { + var allowedHeaders = options.allowedHeaders || options.headers; + var headers = []; + + if (!allowedHeaders) { + allowedHeaders = req.headers['access-control-request-headers']; // .headers wasn't specified, so reflect the request headers + headers.push([{ + key: 'Vary', + value: 'Access-Control-Request-Headers' + }]); + } else if (allowedHeaders.join) { + allowedHeaders = allowedHeaders.join(','); // .headers is an array, so turn it into a string + } + if (allowedHeaders && allowedHeaders.length) { + headers.push([{ + key: 'Access-Control-Allow-Headers', + value: allowedHeaders + }]); + } + + return headers; + } + + function configureExposedHeaders(options) { + var headers = options.exposedHeaders; + if (!headers) { + return null; + } else if (headers.join) { + headers = headers.join(','); // .headers is an array, so turn it into a string + } + if (headers && headers.length) { + return { + key: 'Access-Control-Expose-Headers', + value: headers + }; + } + return null; + } + + function configureMaxAge(options) { + var maxAge = (typeof options.maxAge === 'number' || options.maxAge) && options.maxAge.toString() + if (maxAge && maxAge.length) { + return { + key: 'Access-Control-Max-Age', + value: maxAge + }; + } + return null; + } + + function applyHeaders(headers, res) { + for (var i = 0, n = headers.length; i < n; i++) { + var header = headers[i]; + if (header) { + if (Array.isArray(header)) { + applyHeaders(header, res); + } else if (header.key === 'Vary' && header.value) { + vary(res, header.value); + } else if (header.value) { + res.setHeader(header.key, header.value); + } + } + } + } + + function cors(options, req, res, next) { + var headers = [], + method = req.method && req.method.toUpperCase && req.method.toUpperCase(); + + if (method === 'OPTIONS') { + // preflight + headers.push(configureOrigin(options, req)); + headers.push(configureCredentials(options)) + headers.push(configureMethods(options)) + headers.push(configureAllowedHeaders(options, req)); + headers.push(configureMaxAge(options)) + headers.push(configureExposedHeaders(options)) + applyHeaders(headers, res); + + if (options.preflightContinue) { + next(); + } else { + // Safari (and potentially other browsers) need content-length 0, + // for 204 or they just hang waiting for a body + res.statusCode = options.optionsSuccessStatus; + res.setHeader('Content-Length', '0'); + res.end(); + } + } else { + // actual response + headers.push(configureOrigin(options, req)); + headers.push(configureCredentials(options)) + headers.push(configureExposedHeaders(options)) + applyHeaders(headers, res); + next(); + } + } + + function middlewareWrapper(o) { + // if options are static (either via defaults or custom options passed in), wrap in a function + var optionsCallback = null; + if (typeof o === 'function') { + optionsCallback = o; + } else { + optionsCallback = function (req, cb) { + cb(null, o); + }; + } + + return function corsMiddleware(req, res, next) { + optionsCallback(req, function (err, options) { + if (err) { + next(err); + } else { + var corsOptions = assign({}, defaults, options); + var originCallback = null; + if (corsOptions.origin && typeof corsOptions.origin === 'function') { + originCallback = corsOptions.origin; + } else if (corsOptions.origin) { + originCallback = function (origin, cb) { + cb(null, corsOptions.origin); + }; + } + + if (originCallback) { + originCallback(req.headers.origin, function (err2, origin) { + if (err2 || !origin) { + next(err2); + } else { + corsOptions.origin = origin; + cors(corsOptions, req, res, next); + } + }); + } else { + next(); + } + } + }); + }; + } + + // can pass either an options hash, an options delegate, or nothing + module.exports = middlewareWrapper; + +}()); diff --git a/backend/node_modules/cors/package.json b/backend/node_modules/cors/package.json new file mode 100644 index 0000000..e90bac8 --- /dev/null +++ b/backend/node_modules/cors/package.json @@ -0,0 +1,42 @@ +{ + "name": "cors", + "description": "Node.js CORS middleware", + "version": "2.8.6", + "author": "Troy Goode (https://github.com/troygoode/)", + "license": "MIT", + "keywords": [ + "cors", + "express", + "connect", + "middleware" + ], + "repository": "expressjs/cors", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + }, + "main": "./lib/index.js", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "devDependencies": { + "after": "0.8.2", + "eslint": "7.30.0", + "express": "4.21.2", + "mocha": "9.2.2", + "nyc": "15.1.0", + "supertest": "6.1.3" + }, + "files": [ + "lib/index.js" + ], + "engines": { + "node": ">= 0.10" + }, + "scripts": { + "test": "npm run lint && npm run test-ci", + "test-ci": "nyc --reporter=lcov --reporter=text mocha --require test/support/env", + "lint": "eslint lib test" + } +} diff --git a/backend/node_modules/object-assign/index.js b/backend/node_modules/object-assign/index.js new file mode 100644 index 0000000..0930cf8 --- /dev/null +++ b/backend/node_modules/object-assign/index.js @@ -0,0 +1,90 @@ +/* +object-assign +(c) Sindre Sorhus +@license MIT +*/ + +'use strict'; +/* eslint-disable no-unused-vars */ +var getOwnPropertySymbols = Object.getOwnPropertySymbols; +var hasOwnProperty = Object.prototype.hasOwnProperty; +var propIsEnumerable = Object.prototype.propertyIsEnumerable; + +function toObject(val) { + if (val === null || val === undefined) { + throw new TypeError('Object.assign cannot be called with null or undefined'); + } + + return Object(val); +} + +function shouldUseNative() { + try { + if (!Object.assign) { + return false; + } + + // Detect buggy property enumeration order in older V8 versions. + + // https://bugs.chromium.org/p/v8/issues/detail?id=4118 + var test1 = new String('abc'); // eslint-disable-line no-new-wrappers + test1[5] = 'de'; + if (Object.getOwnPropertyNames(test1)[0] === '5') { + return false; + } + + // https://bugs.chromium.org/p/v8/issues/detail?id=3056 + var test2 = {}; + for (var i = 0; i < 10; i++) { + test2['_' + String.fromCharCode(i)] = i; + } + var order2 = Object.getOwnPropertyNames(test2).map(function (n) { + return test2[n]; + }); + if (order2.join('') !== '0123456789') { + return false; + } + + // https://bugs.chromium.org/p/v8/issues/detail?id=3056 + var test3 = {}; + 'abcdefghijklmnopqrst'.split('').forEach(function (letter) { + test3[letter] = letter; + }); + if (Object.keys(Object.assign({}, test3)).join('') !== + 'abcdefghijklmnopqrst') { + return false; + } + + return true; + } catch (err) { + // We don't expect any of the above to throw, but better to be safe. + return false; + } +} + +module.exports = shouldUseNative() ? Object.assign : function (target, source) { + var from; + var to = toObject(target); + var symbols; + + for (var s = 1; s < arguments.length; s++) { + from = Object(arguments[s]); + + for (var key in from) { + if (hasOwnProperty.call(from, key)) { + to[key] = from[key]; + } + } + + if (getOwnPropertySymbols) { + symbols = getOwnPropertySymbols(from); + for (var i = 0; i < symbols.length; i++) { + if (propIsEnumerable.call(from, symbols[i])) { + to[symbols[i]] = from[symbols[i]]; + } + } + } + } + + return to; +}; diff --git a/backend/node_modules/object-assign/license b/backend/node_modules/object-assign/license new file mode 100644 index 0000000..654d0bf --- /dev/null +++ b/backend/node_modules/object-assign/license @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/backend/node_modules/object-assign/package.json b/backend/node_modules/object-assign/package.json new file mode 100644 index 0000000..503eb1e --- /dev/null +++ b/backend/node_modules/object-assign/package.json @@ -0,0 +1,42 @@ +{ + "name": "object-assign", + "version": "4.1.1", + "description": "ES2015 `Object.assign()` ponyfill", + "license": "MIT", + "repository": "sindresorhus/object-assign", + "author": { + "name": "Sindre Sorhus", + "email": "sindresorhus@gmail.com", + "url": "sindresorhus.com" + }, + "engines": { + "node": ">=0.10.0" + }, + "scripts": { + "test": "xo && ava", + "bench": "matcha bench.js" + }, + "files": [ + "index.js" + ], + "keywords": [ + "object", + "assign", + "extend", + "properties", + "es2015", + "ecmascript", + "harmony", + "ponyfill", + "prollyfill", + "polyfill", + "shim", + "browser" + ], + "devDependencies": { + "ava": "^0.16.0", + "lodash": "^4.16.4", + "matcha": "^0.7.0", + "xo": "^0.16.0" + } +} diff --git a/backend/node_modules/vary/HISTORY.md b/backend/node_modules/vary/HISTORY.md new file mode 100644 index 0000000..f6cbcf7 --- /dev/null +++ b/backend/node_modules/vary/HISTORY.md @@ -0,0 +1,39 @@ +1.1.2 / 2017-09-23 +================== + + * perf: improve header token parsing speed + +1.1.1 / 2017-03-20 +================== + + * perf: hoist regular expression + +1.1.0 / 2015-09-29 +================== + + * Only accept valid field names in the `field` argument + - Ensures the resulting string is a valid HTTP header value + +1.0.1 / 2015-07-08 +================== + + * Fix setting empty header from empty `field` + * perf: enable strict mode + * perf: remove argument reassignments + +1.0.0 / 2014-08-10 +================== + + * Accept valid `Vary` header string as `field` + * Add `vary.append` for low-level string manipulation + * Move to `jshttp` orgainzation + +0.1.0 / 2014-06-05 +================== + + * Support array of fields to set + +0.0.0 / 2014-06-04 +================== + + * Initial release diff --git a/backend/node_modules/vary/LICENSE b/backend/node_modules/vary/LICENSE new file mode 100644 index 0000000..84441fb --- /dev/null +++ b/backend/node_modules/vary/LICENSE @@ -0,0 +1,22 @@ +(The MIT License) + +Copyright (c) 2014-2017 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/backend/node_modules/vary/index.js b/backend/node_modules/vary/index.js new file mode 100644 index 0000000..5b5e741 --- /dev/null +++ b/backend/node_modules/vary/index.js @@ -0,0 +1,149 @@ +/*! + * vary + * Copyright(c) 2014-2017 Douglas Christopher Wilson + * MIT Licensed + */ + +'use strict' + +/** + * Module exports. + */ + +module.exports = vary +module.exports.append = append + +/** + * RegExp to match field-name in RFC 7230 sec 3.2 + * + * field-name = token + * token = 1*tchar + * tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" + * / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" + * / DIGIT / ALPHA + * ; any VCHAR, except delimiters + */ + +var FIELD_NAME_REGEXP = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/ + +/** + * Append a field to a vary header. + * + * @param {String} header + * @param {String|Array} field + * @return {String} + * @public + */ + +function append (header, field) { + if (typeof header !== 'string') { + throw new TypeError('header argument is required') + } + + if (!field) { + throw new TypeError('field argument is required') + } + + // get fields array + var fields = !Array.isArray(field) + ? parse(String(field)) + : field + + // assert on invalid field names + for (var j = 0; j < fields.length; j++) { + if (!FIELD_NAME_REGEXP.test(fields[j])) { + throw new TypeError('field argument contains an invalid header name') + } + } + + // existing, unspecified vary + if (header === '*') { + return header + } + + // enumerate current values + var val = header + var vals = parse(header.toLowerCase()) + + // unspecified vary + if (fields.indexOf('*') !== -1 || vals.indexOf('*') !== -1) { + return '*' + } + + for (var i = 0; i < fields.length; i++) { + var fld = fields[i].toLowerCase() + + // append value (case-preserving) + if (vals.indexOf(fld) === -1) { + vals.push(fld) + val = val + ? val + ', ' + fields[i] + : fields[i] + } + } + + return val +} + +/** + * Parse a vary header into an array. + * + * @param {String} header + * @return {Array} + * @private + */ + +function parse (header) { + var end = 0 + var list = [] + var start = 0 + + // gather tokens + for (var i = 0, len = header.length; i < len; i++) { + switch (header.charCodeAt(i)) { + case 0x20: /* */ + if (start === end) { + start = end = i + 1 + } + break + case 0x2c: /* , */ + list.push(header.substring(start, end)) + start = end = i + 1 + break + default: + end = i + 1 + break + } + } + + // final token + list.push(header.substring(start, end)) + + return list +} + +/** + * Mark that a request is varied on a header field. + * + * @param {Object} res + * @param {String|Array} field + * @public + */ + +function vary (res, field) { + if (!res || !res.getHeader || !res.setHeader) { + // quack quack + throw new TypeError('res argument is required') + } + + // get existing header + var val = res.getHeader('Vary') || '' + var header = Array.isArray(val) + ? val.join(', ') + : String(val) + + // set new header + if ((val = append(header, field))) { + res.setHeader('Vary', val) + } +} diff --git a/backend/node_modules/vary/package.json b/backend/node_modules/vary/package.json new file mode 100644 index 0000000..028f72a --- /dev/null +++ b/backend/node_modules/vary/package.json @@ -0,0 +1,43 @@ +{ + "name": "vary", + "description": "Manipulate the HTTP Vary header", + "version": "1.1.2", + "author": "Douglas Christopher Wilson ", + "license": "MIT", + "keywords": [ + "http", + "res", + "vary" + ], + "repository": "jshttp/vary", + "devDependencies": { + "beautify-benchmark": "0.2.4", + "benchmark": "2.1.4", + "eslint": "3.19.0", + "eslint-config-standard": "10.2.1", + "eslint-plugin-import": "2.7.0", + "eslint-plugin-markdown": "1.0.0-beta.6", + "eslint-plugin-node": "5.1.1", + "eslint-plugin-promise": "3.5.0", + "eslint-plugin-standard": "3.0.1", + "istanbul": "0.4.5", + "mocha": "2.5.3", + "supertest": "1.1.0" + }, + "files": [ + "HISTORY.md", + "LICENSE", + "README.md", + "index.js" + ], + "engines": { + "node": ">= 0.8" + }, + "scripts": { + "bench": "node benchmark/index.js", + "lint": "eslint --plugin markdown --ext js,md .", + "test": "mocha --reporter spec --bail --check-leaks test/", + "test-cov": "istanbul cover node_modules/mocha/bin/_mocha -- --reporter dot --check-leaks test/", + "test-travis": "istanbul cover node_modules/mocha/bin/_mocha --report lcovonly -- --reporter spec --check-leaks test/" + } +} diff --git a/db/.env b/db/.env index ff8d59b..895b0d1 100644 --- a/db/.env +++ b/db/.env @@ -1,4 +1,3 @@ -DATBASE_URL=postgres://postgres:purple123!@localhost:5432/purpleforest JWT_SECRET_KEY=supersecretkey123 POSTGRES_USER=postgres POSTGRES_PASSWORD=purple123! diff --git a/front-end/components/bloom.mjs b/front-end/components/bloom.mjs index 0b4166c..05d2608 100644 --- a/front-end/components/bloom.mjs +++ b/front-end/components/bloom.mjs @@ -17,10 +17,12 @@ const createBloom = (template, bloom) => { const bloomArticle = bloomFrag.querySelector("[data-bloom]"); const bloomUsername = bloomFrag.querySelector("[data-username]"); + const bloomRebloomBtn = bloomFrag.querySelector("[data-action='rebloom']"); + const rebloomCount = bloomFrag.querySelector("[data-rebloom-count]"); //creates rebloom action const bloomTime = bloomFrag.querySelector("[data-time]"); const bloomTimeLink = bloomFrag.querySelector("a:has(> [data-time])"); const bloomContent = bloomFrag.querySelector("[data-content]"); - + bloomArticle.setAttribute("data-bloom-id", bloom.id); bloomUsername.setAttribute("href", `/profile/${bloom.sender}`); bloomUsername.textContent = bloom.sender; @@ -31,6 +33,10 @@ const createBloom = (template, bloom) => { .body.childNodes ); + bloomRebloomBtn.dataset.bloomId = bloom.id; +rebloomCount.textContent = bloom.rebloom_count || 0; +bloomArticle.setAttribute("data-bloom-id", bloom.id); + return bloomFrag; }; diff --git a/front-end/index.html b/front-end/index.html index b1793d4..2f5f6fd 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -233,12 +233,26 @@

Share a Bloom

diff --git a/front-end/index.mjs b/front-end/index.mjs index be49922..878cfbf 100644 --- a/front-end/index.mjs +++ b/front-end/index.mjs @@ -45,6 +45,29 @@ async function init() { }); } +document.addEventListener("click", async (e) => { + if (e.target.dataset.action === "rebloom") { + const bloomId = Number(e.target.dataset.bloomId); + + if (!bloomId) return; + + try { + const result = await apiService.rebloomBloom(bloomId); + if (result.success) { + // Look for the span with the data attribute + const rebloomContainer = e.target.closest(".bloom__rebloom"); + const countElem = rebloomContainer?.querySelector("[data-rebloom-count]"); + + if (countElem) { + countElem.textContent = result.rebloom_count || 1; + } + } + } catch (error) { + handleErrorDialog(error); + } + } +}); + // TODO Check any unhandled errors bubble up to this central handler window.onload = () => { init().catch((error) => { From 7d67883958ea2377526703d9bb808f172cedbeb6 Mon Sep 17 00:00:00 2001 From: Faithy4444 <161722786+Faithy4444@users.noreply.github.com> Date: Thu, 29 Jan 2026 21:49:18 +0200 Subject: [PATCH 10/10] rebloom working for all users --- backend/data/blooms.py | 91 +++++++++++++++++++++++++++++++----------- backend/endpoints.py | 44 +++++++++++++------- backend/main.py | 4 +- 3 files changed, 101 insertions(+), 38 deletions(-) diff --git a/backend/data/blooms.py b/backend/data/blooms.py index 5f947f4..654d6b0 100644 --- a/backend/data/blooms.py +++ b/backend/data/blooms.py @@ -1,10 +1,11 @@ import datetime - +import re from dataclasses import dataclass from typing import Any, Dict, List, Optional from data.connection import db_cursor from data.users import User +from data.users import get_user @dataclass @@ -13,6 +14,8 @@ class Bloom: sender: User content: str sent_timestamp: datetime.datetime + rebloom_count: int = 0 + def add_bloom(*, sender: User, content: str) -> Bloom: @@ -58,7 +61,8 @@ def get_blooms_for_user( cur.execute( f"""SELECT - blooms.id, users.username, content, send_timestamp + blooms.id, users.username, content, send_timestamp, + COALESCE((SELECT COUNT(*) FROM reblooms r WHERE r.bloom_id = blooms.id), 0) as rebloom_count FROM blooms INNER JOIN users ON users.id = blooms.sender_id WHERE @@ -72,15 +76,18 @@ def get_blooms_for_user( rows = cur.fetchall() blooms = [] for row in rows: - bloom_id, sender_username, content, timestamp = row - blooms.append( - Bloom( - id=bloom_id, - sender=sender_username, - content=content, - sent_timestamp=timestamp, - ) - ) + bloom_id, sender_username, content, timestamp, rebloom_count = row + sender_user = get_user(sender_username) + if sender_user: + blooms.append( + Bloom( + id=bloom_id, + sender=sender_user, + content=content, + sent_timestamp=timestamp, + rebloom_count=rebloom_count, + ) + ) return blooms @@ -94,12 +101,15 @@ def get_bloom(bloom_id: int) -> Optional[Bloom]: if row is None: return None bloom_id, sender_username, content, timestamp = row - return Bloom( - id=bloom_id, - sender=sender_username, - content=content, - sent_timestamp=timestamp, + sender_user = get_user(sender_username) + if sender_user: + return Bloom( + id=bloom_id, + sender=sender_user, + content=content, + sent_timestamp=timestamp, ) + return None def get_blooms_with_hashtag( @@ -126,12 +136,14 @@ def get_blooms_with_hashtag( blooms = [] for row in rows: bloom_id, sender_username, content, timestamp = row - blooms.append( - Bloom( - id=bloom_id, - sender=sender_username, - content=content, - sent_timestamp=timestamp, + sender_user = get_user(sender_username) + if sender_user: + blooms.append( + Bloom( + id=bloom_id, + sender=sender_user, + content=content, + sent_timestamp=timestamp, ) ) return blooms @@ -168,4 +180,37 @@ def get_rebloom_count(bloom_id: int) -> int: (bloom_id,), ) count = cur.fetchone()[0] - return count \ No newline at end of file + return count + +def get_user_reblooms(username: str, limit: Optional[int] = 50) -> List[Bloom]: + from data.users import get_user + with db_cursor() as cur: + kwargs = {"username": username} + limit_clause = make_limit_clause(limit, kwargs) + + cur.execute(f""" + SELECT DISTINCT + blooms.id, users.username as sender_username, blooms.content, blooms.send_timestamp, + COALESCE((SELECT COUNT(*) FROM reblooms r WHERE r.bloom_id = blooms.id), 0) as rebloom_count + FROM reblooms r + INNER JOIN blooms ON r.bloom_id = blooms.id + INNER JOIN users ON blooms.sender_id = users.id + WHERE r.user_id = (SELECT id FROM users WHERE username = %(username)s) + ORDER BY blooms.send_timestamp DESC + {limit_clause} + """, kwargs) + + rows = cur.fetchall() + reblooms = [] + for row in rows: + bloom_id, sender_username, content, timestamp, rebloom_count = row + sender_user = get_user(sender_username) + if sender_user: + reblooms.append(Bloom( + id=bloom_id, + sender=sender_user, + content=content, + sent_timestamp=timestamp, + rebloom_count=rebloom_count, + )) + return reblooms \ No newline at end of file diff --git a/backend/endpoints.py b/backend/endpoints.py index 5b80b30..1450b65 100644 --- a/backend/endpoints.py +++ b/backend/endpoints.py @@ -1,11 +1,13 @@ from typing import Dict, Union from data import blooms +from data.blooms import get_user_reblooms from data.follows import follow, get_followed_usernames, get_inverse_followed_usernames from data.users import ( UserRegistrationError, get_suggested_follows, get_user, register_user, + ) from flask import Response, jsonify, make_response, request @@ -112,18 +114,21 @@ def other_profile(profile_username): followers = get_inverse_followed_usernames(profile_user) all_blooms = blooms.get_blooms_for_user(profile_username) all_blooms.reverse() - return jsonify( - { - "username": profile_username, - "recent_blooms": all_blooms[:10], - "follows": get_followed_usernames(profile_user), - "followers": list(followers), - "is_following": current_user is not None - and current_user.username in followers, - "is_self": current_user is not None - and current_user.username == profile_username, - "total_blooms": len(all_blooms), - } + return jsonify({ + "username": profile_username, + "recent_blooms": [{ + "id": b.id, + "sender": {"id": b.sender.id, "username": b.sender.username}, + "content": b.content, + "sent_timestamp": b.sent_timestamp.isoformat(), + "rebloom_count": getattr(b, 'rebloom_count', 0) + } for b in all_blooms[:10]], + "follows": get_followed_usernames(profile_user), + "followers": list(followers), + "is_following": current_user is not None and current_user.username in followers, + "is_self": current_user is not None and current_user.username == profile_username, + "total_blooms": len(all_blooms), + } ) @@ -197,6 +202,11 @@ def rebloom(): "rebloom_count": count }) +def user_blooms(profile_username): + """Get all blooms for user profile""" + user_blooms_list = blooms.get_blooms_for_user(profile_username) + user_blooms_list.reverse() + return jsonify(user_blooms_list) @jwt_required() def home_timeline(): @@ -222,9 +232,15 @@ def home_timeline(): all_blooms = followed_blooms + own_blooms + user_reblooms # 6. Sort newest first - sorted_blooms = sorted(all_blooms, key=lambda b: b['sent_timestamp'], reverse=True) + sorted_blooms = sorted(all_blooms, key=lambda b: b.sent_timestamp, reverse=True) - return jsonify(sorted_blooms) + return jsonify([{ + "id": b.id, + "sender": {"id": b.sender.id, "username": b.sender.username}, + "content": b.content, + "sent_timestamp": b.sent_timestamp.isoformat(), + "rebloom_count": b.rebloom_count +} for b in sorted_blooms]) @jwt_required() diff --git a/backend/main.py b/backend/main.py index 2a5fc46..3b641aa 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,6 +14,7 @@ self_profile, send_bloom, suggested_follows, + user_blooms, ) from dotenv import load_dotenv @@ -59,8 +60,9 @@ def main(): app.add_url_rule("/bloom", methods=["POST"], view_func=send_bloom) app.add_url_rule("/bloom/", methods=["GET"], view_func=get_bloom) app.add_url_rule("/hashtag/", view_func=hashtag) - app.add_url_rule("/reblooms", methods=["POST"], view_func=rebloom) + app.add_url_rule("/blooms/", view_func=user_blooms) + app.run(host="0.0.0.0", port="3000", debug=True)