From 52869389f859f7da32fc11511dd9bca6508c88a7 Mon Sep 17 00:00:00 2001 From: LQ458 Date: Wed, 22 Jul 2026 15:04:49 +0800 Subject: [PATCH 01/31] chore: record verified production baseline --- docs/evidence/README.md | 28 +++++ docs/evidence/collection-method.md | 71 +++++++++++ docs/evidence/metric-catalog.md | 98 +++++++++++++++ .../production-baseline-2026-07-22.md | 118 ++++++++++++++++++ docs/operations/phase-0-report.md | 53 ++++++++ 5 files changed, 368 insertions(+) create mode 100644 docs/evidence/README.md create mode 100644 docs/evidence/collection-method.md create mode 100644 docs/evidence/metric-catalog.md create mode 100644 docs/evidence/production-baseline-2026-07-22.md create mode 100644 docs/operations/phase-0-report.md diff --git a/docs/evidence/README.md b/docs/evidence/README.md new file mode 100644 index 0000000..6dc41e3 --- /dev/null +++ b/docs/evidence/README.md @@ -0,0 +1,28 @@ +# Evidence and metric records + +This directory contains public, redacted evidence records for BiphFlora. +It is intentionally limited to aggregate values, reproducible methods, and +explicit limitations. It must never contain credentials, server addresses, +raw access logs, raw user agents, account identifiers, media filenames, or +environment-variable values. + +The current baseline is [production-baseline-2026-07-22.md](production-baseline-2026-07-22.md). +The metric register is [metric-catalog.md](metric-catalog.md), and collection +rules are in [collection-method.md](collection-method.md). + +## Status vocabulary + +- Direct: measured from the authoritative source at the stated collection time. +- Proxy: useful but not equivalent to the requested business metric. +- Unavailable: no trustworthy source or calculation currently exists. +- Candidate: evidence of an event that still cannot prove the event by itself. + +No metric may be promoted from proxy or unavailable to direct without adding +the source, calculation, filtering rules, collection date, and limitations. + +## Public and private boundary + +The corresponding private evidence register keeps operational source locations, +command/query text, and backup-review notes. It still excludes secret values, +raw logs, personal data, IP addresses, and unredacted user feedback. Git keeps +only this redacted version so the public repository remains safe to share. diff --git a/docs/evidence/collection-method.md b/docs/evidence/collection-method.md new file mode 100644 index 0000000..dcc1b19 --- /dev/null +++ b/docs/evidence/collection-method.md @@ -0,0 +1,71 @@ +# Evidence collection method + +## Collection principles + +1. Treat MongoDB, filesystem media, Redis, logs, deployment metadata, backups, + TLS/DNS, and qualitative evidence as separate sources of truth. +2. Prefer read-only aggregation on the production host. Do not copy raw access + logs, media files, database dumps, credentials, tokens, or user records to + the repository. +3. Record a source's grain before interpreting it. For example, an Nginx log + entry is a request, not a session or a verified human. +4. Preserve the exact calculation and collection date for every number. +5. Mark missing evidence as unavailable rather than estimating a favorable + value. + +## Current source-to-metric mapping + +| Source class | Safe aggregate use | Not safe to infer | +| --- | --- | --- | +| MongoDB collections | Current document counts, authorization state, ObjectId bounds, logical database size | Deleted historical records, active users, completed workflows without audit events | +| Media filesystem | File count, byte totals, existence of a path referenced by a document | Unique image count, image quality, compression rate, successful upload count | +| Redis | Current key count and persistence mode | Session count, user count, cache-hit rate | +| Nginx access log | Unfiltered request-entry range and later filtered request/session estimates | Verified unique people, client-side SPA behavior, uptime | +| Application logs | Log retention range and aggregated error candidates | Error rate or incident history without a denominator and classification | +| Git/reflog/process metadata | Current deployed commit and deployment candidates | Total deployment count or a successful deployment by itself | +| Backup directories | Presence, age, and aggregate file metadata | Recoverability or coverage until a restore test proves it | +| RDAP, DNS, TLS, Wayback | Registration, certificate, and publicly observable history | Continuous availability or internal launch date | + +## Traffic calculation rule + +When traffic aggregation is later approved, the report must retain the source +log on the server and save only aggregates. A candidate session is a sequence +of included requests sharing a privacy-preserving signature with no more than +30 minutes of inactivity. Exclude static assets, health checks, recognized +bots/crawlers, obvious scanners, known internal testing, administration routes, +and anomalous refresh bursts. A signature produced from IP and user-agent is an +estimated visitor signature, never a verified unique visitor. + +## Media calculation rule + +Before a media optimization or cleanup: + +1. Generate a file manifest with relative paths, byte sizes, and checksums. +2. Compare each MongoDB path reference to the original and derivative files. +3. Classify unmatched, duplicated, and unreferenced files without deleting any. +4. Calculate original and derivative size distributions only from a documented + matched sample or full matched population. +5. Require a verified backup, dry-run manifest, and isolated staging sample + before writing derivatives or changing path references. + +## Evidence record template + +Metric: + +Exact definition: + +Value: + +Time window: + +Source: + +Query/calculation: + +Bot/internal-traffic filtering: + +Collected on: + +Confidence: + +Limitations: diff --git a/docs/evidence/metric-catalog.md b/docs/evidence/metric-catalog.md new file mode 100644 index 0000000..cd97aea --- /dev/null +++ b/docs/evidence/metric-catalog.md @@ -0,0 +1,98 @@ +# Metric catalog and evidence status + +This catalog makes the requested evidence durable before new telemetry changes +the product. Every future measurement must use the evidence-record template in +[collection-method.md](collection-method.md). + +## Six priority metrics + +| Metric | Current value | Evidence status | Limitation | +| --- | --- | --- | --- | +| Public operation lower bound | Public snapshot on 2024-01-15; observed again on 2026-07-22 | Direct for observations | Does not prove continuous service or uptime | +| Valid visits/sessions | Unavailable | Unavailable | Requires server-side filtered aggregation with a retained rule version | +| Verified unique visitors | Unavailable | Unavailable | No identity-preserving, consented analytics source exists | +| Current plant records | 152 authorized; 153 total including 1 pending | Direct | Does not include deleted history | +| Current image/works documents | 970 Pic and 27 Art documents | Direct | Not a unique-image count | +| Real searches, edits, uploads, contributor or organization use | Unavailable | Unavailable | Existing data lacks an append-only event/audit source and consented qualitative evidence | + +## Lifecycle and deployment + +| Metric | Current value | Status | Source and limitation | +| --- | --- | --- | --- | +| First public launch date | Unavailable | Unavailable | Earliest retained public snapshot is only a lower bound | +| Earliest public snapshot | 2024-01-15 | Direct | Internet Archive retained-snapshot record | +| Domain registration date | 2023-10-12 | Direct | Public RDAP registration event | +| Current deployed commit | ea471b6 | Direct | Production Git checkout at collection time | +| Current deployment date | 2025-12-19 candidate | Candidate | Latest retained pull-origin-main reflog entry; not a completed deployment proof | +| Continuous service duration | Unavailable | Unavailable | No availability monitor or complete incident history | +| Interrupted-service periods | Unavailable | Unavailable | No verified incident timeline | +| Major versions/features | Partially available in Git history | Proxy | Requires a reviewed release timeline | +| Total deployments/recent deployment | Unavailable / candidate above | Unavailable | Git pull and process dates cannot establish all deployments | +| Server/platform migrations | Unavailable | Unavailable | Needs configuration/history evidence | +| Incidents and recovery times | Unavailable | Unavailable | Logs are not classified incidents | +| Production/staging/local relationship | Production and local commit observed; no staging found | Direct/partial | Need isolated staging before deployment approval | + +## Traffic and audience + +| Metric | Current value | Status | Source and limitation | +| --- | --- | --- | --- | +| Unfiltered access-log entries | 1,131,038 | Direct | Request entries from 2024-10-31 to 2026-07-22; not visits | +| Pageviews | Unavailable | Unavailable | SPA navigation and filtering are not captured defensibly | +| Visits/sessions | Unavailable | Unavailable | Requires documented 30-minute session aggregation | +| Verified unique visitors | Unavailable | Unavailable | No suitable first-party identity source | +| Recent 30/90/365-day traffic | Unavailable | Unavailable | Pending approved log aggregation | +| Monthly/daily averages and trend | Unavailable | Unavailable | Pending approved log aggregation | +| Peak daily/hourly traffic | Unavailable | Unavailable | Pending approved log aggregation | +| New/returning share | Unavailable | Unavailable | No consented visitor identity source | +| Visit duration/pages/bounce rate | Unavailable | Unavailable | Nginx alone cannot observe SPA completion or duration | +| Source, device, country distribution | Unavailable | Unavailable | Needs filtered, privacy-preserving aggregation | +| Most visited pages | Unavailable | Unavailable | Needs route-level aggregation and SPA caveat | +| HTTP request total | 1,131,038 frontend access-log entries | Direct | Operational request volume only; never label as visits | + +## Product usage and contribution + +| Metric | Current value | Status | Source and limitation | +| --- | --- | --- | --- | +| Authorized plant records | 152 | Direct | Current MongoDB posts with authorization true | +| Pending plant records | 1 | Direct | Current MongoDB posts not authorized | +| Historical records created | Unavailable | Unavailable | Deleted documents are not recoverable from current collections | +| Pic documents | 970 | Direct | Current MongoDB Pic collection | +| Art documents | 27 | Direct | Current MongoDB Art collection | +| Media filesystem footprint | 1,245 files / 2,332,361,589 bytes | Direct | Includes non-record files and derivatives | +| Current registered accounts | 3 | Direct | Current MongoDB user documents; not active users | +| Searches, success rate, zero-result rate, language use | Unavailable | Unavailable | No search event telemetry | +| Detail views, uploads, creates, edits, reviews | Unavailable | Unavailable | No append-only operation audit | +| Actual contributors and monthly active contributors | Unavailable | Unavailable | Account/editor fields do not verify distinct people or activity | +| Browser/contributor ratio | Unavailable | Unavailable | Requires both traffic and auditable contribution events | +| Organization workflows and feedback | Unavailable | Unavailable | Needs consented, reviewed qualitative evidence | +| Core versus unused functions | Unavailable | Unavailable | Needs audited usage events and qualitative review | + +## Reliability, media, search, and engineering + +| Metric | Current value | Status | Source and limitation | +| --- | --- | --- | --- | +| Point-in-time public HTTPS | HTTP 200 | Direct | Observed after the stage-0 audit only | +| Uptime/availability/downtime/MTTR | Unavailable | Unavailable | No historical monitor coverage | +| HTTP error rate / unhandled exceptions | Unavailable | Unavailable | Need classified status/error aggregation | +| p50/p95/p99, search, upload, database latency | Unavailable | Unavailable | No latency telemetry | +| CPU/memory/disk state | Point-in-time baseline collected privately | Direct | Not a capacity trend or performance claim | +| TLS validity | 2026-02-25 through 2027-02-25 | Direct | Certificate dates only | +| Automated restart/health/alerting | Partial process management; no app health endpoint | Direct/partial | Health endpoint and alert coverage must be added and verified | +| Backup restore success | Unavailable | Unavailable | Presence of backup files is not a restore test | +| Pic/Art path existence | 997 of 997 document paths present | Direct | Does not prove visual validity or unique physical files | +| Average size, compression ratio, storage saved | Unavailable | Unavailable | Requires matched original/derivative analysis | +| Image format/limit/process failure rate | Unavailable | Unavailable | Need validated server rules and operation telemetry | +| Multilingual/fuzzy search quality and index latency | Implementation exists; quality metric unavailable | Partial | Requires a test corpus and measured queries | +| Architecture/API/schema/auth model | Source-reviewed baseline available | Direct/partial | Must be kept current as modules move | +| Test coverage/CI/security scan | Incomplete | Direct | Current test command and CI do not yet provide a trustworthy coverage baseline | + +## Guardrails for future reporting + +- Never call registered accounts active users. +- Never call access-log requests visits, sessions, pageviews, or unique people. +- Never infer historical uptime from a start date, a Git commit, or a current + healthy response. +- Never claim image compression, performance, or reliability improvements + without a comparable pre/post method and recorded sample or population. +- Never publish names, accounts, IP addresses, raw user feedback, or private + operational configuration without explicit permission. diff --git a/docs/evidence/production-baseline-2026-07-22.md b/docs/evidence/production-baseline-2026-07-22.md new file mode 100644 index 0000000..7f54cdc --- /dev/null +++ b/docs/evidence/production-baseline-2026-07-22.md @@ -0,0 +1,118 @@ +# Production baseline — 2026-07-22 + +Collection time: 2026-07-22T07:00:56Z. + +This is a point-in-time, read-only production baseline. It is not an uptime +report, a traffic report, or a backup-restore certification. + +## Verified current state + +| Area | Direct observation | Important limitation | +| --- | --- | --- | +| Deployed source | Commit ea471b6; production checkout is detached | A matching Git commit does not prove every historical deployment | +| Application listeners | Frontend on port 3000 and backend on port 3001 were present | Direct listeners may bypass Nginx and are not a traffic source of record | +| Core services | Nginx, MongoDB, and Redis were active at collection time | This is a point-in-time health observation, not availability | +| Public HTTPS | The public home page returned HTTP 200 after the audit | The application has no dedicated health endpoint yet | +| Plant data | 153 current plant documents: 152 authorized and 1 pending | Current documents exclude deleted historical records | +| Bird data | 0 current bird documents | This does not establish whether bird features were historically used | +| Images and artwork | 970 current Pic documents and 27 current Art documents | Documents are not proven to be unique physical images | +| Media references | All 970 Pic paths and all 27 Art paths mapped to an existing file | This checks existence only; it does not detect duplicate file references or visual correctness | +| Media filesystem | 1,245 files using 2,332,361,589 bytes | Includes originals, derivatives, uploads, assets, and possible archives | +| Media subdirectories | Originals: 1,033 files / 2,109,916,481 bytes; derivatives: 196 / 41,024,169 bytes; uploads: 2 / 6,811,922 bytes | The three folders do not account for every file below public | +| MongoDB logical data | 14 collections; 597,225 data bytes; 741,376 storage bytes; 589,824 index bytes | Separate from media files and MongoDB engine files | +| Redis | 2 keys; RDB persistence enabled, AOF disabled; last RDB save reported successful | Key count is not a session, user, or cache metric | +| Access-log evidence | 1,131,038 unfiltered frontend access-log entries from 2024-10-31 through 2026-07-22 | Entries include requests that may be assets, bots, scans, or internal traffic; they are not visits or users | +| Backups | 151 panel-backup files and 1 root-level database-backup candidate were observed | Scope, retention, restore success, and media/Redis coverage remain unverified | +| TLS | Certificate validity observed from 2026-02-25 through 2027-02-25 | Certificate validity does not prove endpoint availability | + +## Lifecycle evidence + +Metric: Domain registration date + +Exact definition: Registration timestamp returned by the public registry record +for the production domain. + +Value: 2023-10-12T13:35:34Z + +Time window: Domain registration event. + +Source: Verisign RDAP, collected during the baseline research. + +Query/calculation: Public RDAP registration-event field. + +Bot/internal-traffic filtering: Not applicable. + +Collected on: 2026-07-22. + +Confidence: Direct for the registry event. + +Limitations: A registration date is not a launch date. + +Metric: Earliest currently verified public page snapshot + +Exact definition: Earliest retained successful HTML snapshot returned by the +Internet Archive query used for this audit. + +Value: 2024-01-15. + +Time window: Retained public snapshots available to the query. + +Source: Internet Archive CDX. + +Query/calculation: Successful HTML snapshots for the production root URL, +collapsed by digest. + +Bot/internal-traffic filtering: Not applicable. + +Collected on: 2026-07-22. + +Confidence: Direct for the retained snapshot record. + +Limitations: It is a lower bound for public visibility, not proof of the first +launch, continuous service, or uptime. + +Metric: Current deployment candidate + +Exact definition: Most recent Git reflog entry matching a pull from the main +branch in the active production checkout. + +Value: 2025-12-19T13:10:32+08:00. + +Time window: Current checkout reflog retained on the host. + +Source: Production Git reflog. + +Query/calculation: First matching pull-origin-main reflog record. + +Bot/internal-traffic filtering: Not applicable. + +Collected on: 2026-07-22. + +Confidence: Candidate. + +Limitations: A Git pull does not prove a completed application build, process +restart, or user-visible deployment. Total deployment count remains unavailable. + +## Operational finding + +An empty PM2 daemon created during the initial read-only inspection was +rechecked before cleanup. It reported zero managed applications and was +subsequently absent. The application listener PIDs stayed unchanged; Nginx, +MongoDB, Redis, and public HTTPS remained healthy. No application process, +database record, media file, Nginx configuration, or deployment was changed. + +## Explicitly unavailable claims + +- Historical uptime, availability percentage, downtime total, incident count, + recovery time, and latency percentiles. +- Valid visits/sessions, verified unique visitors, pageviews, source mix, + device mix, geography, bounce rate, pages per visit, and visit duration. +- Historical searches, successful searches, zero-result searches, uploads, + edits, approvals, account activity, and real contributor counts. +- Average image size, compression ratio, storage savings, image-processing + failure rate, search latency, cache-hit rate, database latency, and capacity + trends. +- A verified backup restore, staging parity, or end-to-end recovery test. + +These fields remain unavailable until instrumentation or a defensible +server-side aggregation produces a documented value. diff --git a/docs/operations/phase-0-report.md b/docs/operations/phase-0-report.md new file mode 100644 index 0000000..deb513f --- /dev/null +++ b/docs/operations/phase-0-report.md @@ -0,0 +1,53 @@ +# Phase 0 report — verified production baseline + +Date: 2026-07-22. + +## What changed and why + +- Created the local branch refactor/progressive-stability from ea471b6. +- Added redacted evidence, metric, and collection-method documentation so + future refactors can be evaluated against a known baseline. +- Rechecked and removed the empty PM2 daemon accidentally created during the + initial inspection. It had zero managed applications. +- Created no production code, deployment, configuration, database, Redis, or + media change. + +## Checks and health results + +- Local branch base: ea471b6. +- PM2 daemon: absent after cleanup. +- Application listeners: unchanged on ports 3000 and 3001. +- Nginx, MongoDB, and Redis: active at the post-cleanup check. +- Public home page: HTTP 200 at the post-cleanup check. +- MongoDB media path coverage: 970 of 970 Pic paths and 27 of 27 Art paths + mapped to existing files. +- Evidence collection: aggregate-only MongoDB, media, Redis, log metadata, + Git/reflog, backup metadata, and TLS reads completed. + +## Production impact + +No user-facing application process was restarted or replaced. No database +write, media write, Nginx change, deploy, or backup deletion occurred. The only +production-side mutation was stopping the verified empty PM2 daemon. + +## Unresolved risks + +- There is no staging environment, application health endpoint, or verified + restore test. +- Existing logs cannot directly prove sessions, users, operations, uptime, or + latency. +- Backup directories exist, but their data-domain coverage and recovery + success are unverified. +- Media references exist, but duplicate, orphaned, corrupt, and mismatched + derivative files have not been classified. +- Authorization, plaintext-password storage, test coverage, startup ordering, + image delivery, and documentation drift remain for later phases. + +## Deployment and rollback + +No deployment is proposed for this phase. + +To roll back the repository-only part, revert the commit that adds these +documents. The PM2 cleanup has no business-service rollback because it removed +only an empty daemon; application listeners and service health were verified +afterward. From 01829a445b39ce0f861afed08740202df9e4ccde Mon Sep 17 00:00:00 2001 From: LQ458 Date: Wed, 22 Jul 2026 15:22:07 +0800 Subject: [PATCH 02/31] test: add characterization coverage --- app.js | 68 +++++----- client/src/App.test.js | 63 ++++++++- client/src/pages/Login.js | 8 +- client/src/setupTests.js | 3 + docs/operations/phase-1-report.md | 75 +++++++++++ package.json | 8 +- runtime.js | 148 +++++++++++++++++++++ server.js | 77 +++++++++++ test/app.contract.test.js | 212 ++++++++++++++++++++++++++++++ test/server-lifecycle.test.js | 51 +++++++ 10 files changed, 669 insertions(+), 44 deletions(-) create mode 100644 client/src/setupTests.js create mode 100644 docs/operations/phase-1-report.md create mode 100644 runtime.js create mode 100644 server.js create mode 100644 test/app.contract.test.js create mode 100644 test/server-lifecycle.test.js diff --git a/app.js b/app.js index 8ce2bab..70c5298 100644 --- a/app.js +++ b/app.js @@ -27,12 +27,19 @@ const multer = require("multer"); const featureList = require("./models/featureList"); const creationBottom = require("./models/creationBottom"); const FeatureHome = require("./models/featureHome"); -const redis = require("redis"); +const { createRuntime } = require("./runtime"); const Code = require("./models/code"); const birdPost = require('./models/birdPost') const crypto = require("crypto"); const { log } = require("console"); dotenv.config(); +const runtime = createRuntime({ + mongoose, + redis: require("redis"), + env: process.env, + logger: console, +}); +const redisClient = runtime.redisClient; app.use(compression()); //gzip compression for faster speed app.use(bodyParser.json()); @@ -52,10 +59,18 @@ app.use( }), ); //cors for cross-origin requests -app.use(function (err, req, res, next) { - console.error(err.stack); // Log error stack to console - res.status(500).send("Something broke!"); -}); // Error handling middleware +app.get("/health/live", (req, res) => { + res.status(200).json({ status: "live" }); +}); + +app.get("/health/ready", (req, res) => { + const readiness = runtime.getReadiness(); + + res.status(readiness.ready ? 200 : 503).json({ + status: readiness.ready ? "ready" : "not_ready", + dependencies: readiness.dependencies, + }); +}); const storage = multer.diskStorage({ destination: function (req, file, cb) { @@ -82,34 +97,6 @@ const upload = multer({ const globalUpload = multer({}); -app.listen(process.env.PORT, "0.0.0.0", () => { - console.log("Server is running on port 3001"); -}); // Start the server - -const redisClient = redis.createClient({ - url: process.env.REDIS_URL, - pingInterval: 1000, -}); - -async function connectToRedis() { - try { - await redisClient.connect(); - console.log("Connected to Redis"); - } catch (err) { - console.error("Failed to connect to Redis:", err); - } -} - -connectToRedis(); - -process.on("uncaughtException", function (err) { - console.log("Caught Exception:" + err); //直接捕获method()未定义函数,Node进程未被退出。 -}); - -mongoose.connect(process.env.MONGODB_URL).then(() => { - console.log("connected to mongodb"); -}); // Connect to MongoDB - async function verifyToken(req, res, next) { const token = req.headers["authorization"]; @@ -1838,3 +1825,18 @@ app.post("/getPicsAndArts", async (req, res) => { res.status(500).json({ success: false, message: error.message }); } }); + +app.use(function (err, req, res, next) { + if (res.headersSent) { + return next(err); + } + + console.error("Unhandled request error"); + return res.status(500).send("Something broke!"); +}); + +module.exports = { app, runtime }; + +if (require.main === module) { + require("./server").startServer(); +} diff --git a/client/src/App.test.js b/client/src/App.test.js index 9382b9a..e7f332a 100644 --- a/client/src/App.test.js +++ b/client/src/App.test.js @@ -1,8 +1,61 @@ -import { render, screen } from "@testing-library/react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { MemoryRouter } from "react-router-dom"; +import axios from "axios"; import App from "./App"; -test("renders learn react link", () => { - render(); - const linkElement = screen.getByText(/learn react/i); - expect(linkElement).toBeInTheDocument(); +jest.mock("react-player", () => () => null); + +jest.mock("axios", () => ({ + defaults: {}, + get: jest.fn().mockResolvedValue({ data: {} }), + post: jest.fn(), +})); + +let container; +let root; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + axios.get.mockReset(); + axios.get.mockResolvedValue({ data: {} }); + axios.post.mockReset(); +}); + +afterEach(async () => { + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + container.remove(); +}); + +async function renderAt(pathname) { + await act(async () => { + root.render( + + + , + ); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +test("renders the login route without a network dependency", async () => { + await renderAt("/KQsfhwifheKDFJfkdfjdkfjd3q3puod0d0"); + + expect(container.querySelector("h2").textContent).toBe("Login"); + expect(axios.get).toHaveBeenCalled(); +}); + +test("renders the not-found route", async () => { + await renderAt("/route-that-does-not-exist"); + + expect(container.querySelector(".notFoundTitle").textContent).toBe("404"); }); diff --git a/client/src/pages/Login.js b/client/src/pages/Login.js index 64ca6c0..8057469 100644 --- a/client/src/pages/Login.js +++ b/client/src/pages/Login.js @@ -62,7 +62,7 @@ const Login = () => { }, []); return ( - +
@@ -81,7 +81,7 @@ const Login = () => { value={username} onChange={(e) => setUsername(e.target.value)} /> - +
{ value={password} onChange={(e) => setPassword(e.target.value)} /> - +

- +
); }; diff --git a/client/src/setupTests.js b/client/src/setupTests.js new file mode 100644 index 0000000..558e30c --- /dev/null +++ b/client/src/setupTests.js @@ -0,0 +1,3 @@ +// CRA loads this file before tests. Keep it dependency-free until a compatible +// testing-library package is deliberately introduced and locked. +globalThis.IS_REACT_ACT_ENVIRONMENT = true; diff --git a/docs/operations/phase-1-report.md b/docs/operations/phase-1-report.md new file mode 100644 index 0000000..63573c0 --- /dev/null +++ b/docs/operations/phase-1-report.md @@ -0,0 +1,75 @@ +# Phase 1 report — test guardrails and startup boundary + +Date: 2026-07-22. + +## What changed and why + +- Kept app.js as the compatible root entry point while making imports + side-effect free: importing the Express app no longer starts a listener or + connects to MongoDB or Redis. +- Added runtime.js to own Redis/MongoDB connection state, readiness, and + graceful dependency shutdown. +- Added server.js to own HTTP startup, dependency connection, SIGINT/SIGTERM + handling, and fatal-process shutdown handling. +- Added GET /health/live and GET /health/ready. Live confirms the process can + answer; ready returns 503 until both MongoDB and Redis are ready. Neither + endpoint returns configuration values. +- Moved terminal Express error handling to the end of route registration. +- Replaced the failing placeholder tests with backend read-contract tests and + frontend route smoke tests. Tests use existing Node and CRA tooling only. +- Added root test, build, and verify scripts. Verify runs backend tests, + frontend tests, and a production build. +- Corrected the login page's invalid nested body element and label attributes, + which removed React DOM warnings from the route test. + +## Checks and health results + +- Syntax: app.js, runtime.js, server.js, and both backend test files pass + Node syntax checks. +- Backend: 4 passing tests cover import isolation, live/ready contracts, + public plant/bird search-detail-count-refresh contracts, CORS, and graceful + server shutdown. +- Frontend: 2 passing route smoke tests cover login and unknown-route fallback. +- Unified local verification: npm run verify passed. +- Production build: passed. It required two pre-existing, untracked static + background assets that were read from production without changing production, + checksum-verified locally, and kept Git-ignored. +- Production health check: no new code was deployed. Existing production + services were not restarted during this phase. + +## Production impact + +No production deployment, service restart, database write, Redis write, media +write, Nginx change, or backup change occurred. The new health endpoints exist +only on the local branch until a separately authorized deployment. + +## Unresolved risks + +- There is still no staging environment or verified backup restore. +- Existing npm and pnpm locks differ. Local verification deliberately used the + existing npm lock without changing either lock; lock consolidation remains a + later, separately verified task. +- The client public asset directory is ignored and not reproducible from Git. + Two background assets needed for the local build remain untracked. Do not + treat the local asset copy as a deployment artifact or publish it without an + approved asset/versioning policy. +- The CRA build passes with pre-existing CSS nesting, ESLint, Browserslist, and + unmaintained Babel-preset warnings. Jest diagnostics attribute its open + handle warning to that CRA preset, not application test resources. +- Characterization coverage is deliberately limited to non-destructive public + read flows. Upload, edit, approval, deletion, and authorization tests remain + for the security phase so unsafe current behavior is not normalized. +- Readiness is explicit, but existing route handlers still need later + dependency-failure and authorization hardening. + +## Deployment and rollback + +No deployment is proposed for this phase. + +If deployment is separately approved, build with the same Node/package-manager +decision and verify health endpoints after startup. Deploying must preserve the +production static asset bundle and environment files. + +Rollback is code-only: return to the prior commit, rebuild the client with the +verified static bundle, then perform an authorized service restart. No database +migration or media rollback is needed for this phase. diff --git a/package.json b/package.json index 86dfd66..cc5912b 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,12 @@ "description": "", "main": "index.js", "scripts": { - "start": "nodemon app", - "test": "echo \"Error: no test specified\" && exit 1", + "start": "nodemon app.js", + "test": "npm run test:backend", + "test:backend": "node --test --test-concurrency=1 test/*.test.js", + "test:frontend": "npm --prefix client test -- --watchAll=false --runInBand", + "build": "npm --prefix client run build", + "verify": "npm run test:backend && npm run test:frontend && npm run build", "format": "prettier --write ." }, "keywords": [], diff --git a/runtime.js b/runtime.js new file mode 100644 index 0000000..b53a2a9 --- /dev/null +++ b/runtime.js @@ -0,0 +1,148 @@ +function createRuntime({ + mongoose, + redis, + env = process.env, + logger = console, +}) { + if (!mongoose || !redis) { + throw new Error("Mongoose and Redis clients are required"); + } + + const redisOptions = { pingInterval: 1000 }; + + if (env.REDIS_URL) { + redisOptions.url = env.REDIS_URL; + } + + const redisClient = redis.createClient(redisOptions); + let mongoReady = false; + let redisReady = false; + let mongoOwned = false; + let connectionPromise = null; + + const log = (level, message) => { + if (typeof logger[level] === "function") { + logger[level](message); + } + }; + + redisClient.on("ready", () => { + redisReady = true; + log("info", "Redis connection is ready"); + }); + + redisClient.on("end", () => { + redisReady = false; + log("warn", "Redis connection ended"); + }); + + redisClient.on("error", () => { + redisReady = false; + log("error", "Redis connection error"); + }); + + const getReadiness = () => { + const mongodb = + mongoReady && mongoose.connection && mongoose.connection.readyState === 1; + const redis = redisReady && redisClient.isReady; + + return { + ready: Boolean(mongodb && redis), + dependencies: { + mongodb: Boolean(mongodb), + redis: Boolean(redis), + }, + }; + }; + + const connectMongo = async () => { + if (mongoose.connection && mongoose.connection.readyState === 1) { + mongoReady = true; + return; + } + + if (!env.MONGODB_URL) { + mongoReady = false; + log("warn", "MongoDB configuration is unavailable"); + return; + } + + try { + await mongoose.connect(env.MONGODB_URL); + mongoOwned = true; + mongoReady = true; + log("info", "MongoDB connection is ready"); + } catch (_) { + mongoReady = false; + log("error", "MongoDB connection failed"); + } + }; + + const connectRedis = async () => { + if (redisClient.isReady) { + redisReady = true; + return; + } + + try { + if (!redisClient.isOpen) { + await redisClient.connect(); + } + + redisReady = redisClient.isReady; + } catch (_) { + redisReady = false; + log("error", "Redis connection failed"); + } + }; + + const connect = async () => { + if (!connectionPromise) { + connectionPromise = Promise.all([connectMongo(), connectRedis()]).finally( + () => { + connectionPromise = null; + }, + ); + } + + await connectionPromise; + return getReadiness(); + }; + + const disconnect = async () => { + const tasks = []; + + if (redisClient.isOpen) { + tasks.push( + redisClient.quit().catch(() => { + log("error", "Redis shutdown failed"); + }), + ); + } + + if ( + mongoOwned && + mongoose.connection && + mongoose.connection.readyState !== 0 + ) { + tasks.push( + mongoose.disconnect().catch(() => { + log("error", "MongoDB shutdown failed"); + }), + ); + } + + await Promise.all(tasks); + mongoReady = false; + redisReady = false; + }; + + return { + redisClient, + connect, + disconnect, + getReadiness, + }; +} + +module.exports = { createRuntime }; diff --git a/server.js b/server.js new file mode 100644 index 0000000..c407b3b --- /dev/null +++ b/server.js @@ -0,0 +1,77 @@ +const { app, runtime } = require("./app"); + +function closeServer(server) { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); +} + +function startServer({ + appInstance = app, + runtimeInstance = runtime, + port = Number(process.env.PORT || 3001), + host = process.env.HOST || "0.0.0.0", + logger = console, + registerSignalHandlers = true, +} = {}) { + const server = appInstance.listen(port, host, () => { + logger.info("HTTP server is listening"); + }); + let shutdownPromise = null; + + const shutdown = async (signal = "shutdown") => { + if (!shutdownPromise) { + shutdownPromise = (async () => { + logger.info("Graceful shutdown requested: " + signal); + await closeServer(server); + await runtimeInstance.disconnect(); + })(); + } + + return shutdownPromise; + }; + + runtimeInstance.connect().then((readiness) => { + logger.info( + readiness.ready + ? "Application dependencies are ready" + : "Application dependencies are not ready", + ); + }); + + if (registerSignalHandlers) { + const exitAfterShutdown = (signal) => { + shutdown(signal) + .then(() => { + process.exitCode = 0; + }) + .catch(() => { + process.exitCode = 1; + }); + }; + + process.once("SIGINT", () => exitAfterShutdown("SIGINT")); + process.once("SIGTERM", () => exitAfterShutdown("SIGTERM")); + process.once("uncaughtException", () => + exitAfterShutdown("uncaughtException"), + ); + process.once("unhandledRejection", () => + exitAfterShutdown("unhandledRejection"), + ); + } + + return { server, shutdown }; +} + +if (require.main === module) { + startServer(); +} + +module.exports = { startServer }; diff --git a/test/app.contract.test.js b/test/app.contract.test.js new file mode 100644 index 0000000..c3538a9 --- /dev/null +++ b/test/app.contract.test.js @@ -0,0 +1,212 @@ +const assert = require("node:assert/strict"); +const http = require("node:http"); +const { after, before, test } = require("node:test"); +const { once } = require("node:events"); + +const Post = require("../models/post"); +const BirdPost = require("../models/birdPost"); +const Pic = require("../models/pic"); +const Art = require("../models/art"); +const { app, runtime } = require("../app"); + +let server; +const calls = []; +const originals = { + postFind: Post.find, + birdPostFind: BirdPost.find, + picFind: Pic.find, + artFind: Art.find, +}; + +function request(pathname, { method = "GET", body, headers = {} } = {}) { + const payload = body ? JSON.stringify(body) : null; + + return new Promise((resolve, reject) => { + const requestOptions = { + hostname: "127.0.0.1", + port: server.address().port, + path: pathname, + method, + headers: { + ...headers, + ...(payload + ? { + "content-type": "application/json", + "content-length": Buffer.byteLength(payload), + } + : {}), + }, + }; + const req = http.request(requestOptions, (res) => { + let responseBody = ""; + + res.setEncoding("utf8"); + res.on("data", (chunk) => { + responseBody += chunk; + }); + res.on("end", () => { + resolve({ + status: res.statusCode, + headers: res.headers, + body: responseBody ? JSON.parse(responseBody) : null, + }); + }); + }); + + req.on("error", reject); + if (payload) { + req.write(payload); + } + req.end(); + }); +} + +before(async () => { + Post.find = async (query) => { + calls.push({ model: "Post", query }); + + if (query.latinName) { + return [{ latinName: query.latinName, authorization: true }]; + } + + return [{ latinName: "Acer rubrum", authorization: true }]; + }; + BirdPost.find = async (query) => { + calls.push({ model: "BirdPost", query }); + + if (query.latinName) { + return [{ latinName: query.latinName, authorization: true }]; + } + + return [{ latinName: "Corvus corax", authorization: true }]; + }; + Pic.find = async (query) => { + calls.push({ model: "Pic", query }); + return [{ path: "/plantspic/example.jpg", art: "photography" }]; + }; + Art.find = async (query) => { + calls.push({ model: "Art", query }); + return [{ path: "/plantspic/example-art.jpg" }]; + }; + + server = app.listen(0, "127.0.0.1"); + await once(server, "listening"); +}); + +after(async () => { + Post.find = originals.postFind; + BirdPost.find = originals.birdPostFind; + Pic.find = originals.picFind; + Art.find = originals.artFind; + + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); +}); + +test("importing the application does not make dependencies ready", () => { + assert.deepEqual(runtime.getReadiness(), { + ready: false, + dependencies: { + mongodb: false, + redis: false, + }, + }); +}); + +test("health endpoints expose liveness separately from dependency readiness", async () => { + const live = await request("/health/live"); + const ready = await request("/health/ready"); + + assert.equal(live.status, 200); + assert.deepEqual(live.body, { status: "live" }); + assert.equal(ready.status, 503); + assert.deepEqual(ready.body, { + status: "not_ready", + dependencies: { + mongodb: false, + redis: false, + }, + }); +}); + +test("public read routes retain search, detail, count, refresh, and CORS contracts", async () => { + const search = await request("/searchNames", { + headers: { origin: "https://www.biphflora.com" }, + }); + const birdSearch = await request("/searchBirdNames"); + const plantDetail = await request("/syncPlantInfo", { + method: "POST", + body: { postName: "Acer rubrum" }, + }); + const birdDetail = await request("/syncBirdInfo", { + method: "POST", + body: { postName: "Corvus corax" }, + }); + const plantCount = await request("/numOfPlants"); + const birdCount = await request("/numOfBirds"); + const refresh = await request("/refresh"); + + assert.equal(search.status, 200); + assert.equal( + search.headers["access-control-allow-origin"], + "https://www.biphflora.com", + ); + assert.deepEqual(search.body, { + success: true, + returnNames: [{ latinName: "Acer rubrum", authorization: true }], + numOfPlants: 1, + }); + assert.deepEqual(birdSearch.body, { + success: true, + returnNames: [{ latinName: "Corvus corax", authorization: true }], + numOfPlants: 1, + }); + assert.deepEqual(plantDetail.body, { + resultPost: [{ latinName: "Acer rubrum", authorization: true }], + photographs: [{ path: "/plantspic/example.jpg", art: "photography" }], + arts: [{ path: "/plantspic/example-art.jpg" }], + }); + assert.deepEqual(birdDetail.body, { + resultPost: [{ latinName: "Corvus corax", authorization: true }], + photographs: [{ path: "/plantspic/example.jpg", art: "photography" }], + arts: [{ path: "/plantspic/example-art.jpg" }], + }); + assert.deepEqual(plantCount.body, { numOfPlants: 1 }); + assert.deepEqual(birdCount.body, { numOfPlants: 1 }); + assert.deepEqual(refresh.body, { + success: false, + message: "Token not valid", + }); + assert.deepEqual(calls, [ + { model: "Post", query: { authorization: true } }, + { model: "BirdPost", query: { authorization: true } }, + { + model: "Post", + query: { latinName: "Acer rubrum", authorization: true }, + }, + { + model: "Pic", + query: { art: "photography", plant: "Acer rubrum" }, + }, + { model: "Art", query: { plant: "Acer rubrum" } }, + { + model: "BirdPost", + query: { latinName: "Corvus corax", authorization: true }, + }, + { + model: "Pic", + query: { art: "photography", plant: "Corvus corax" }, + }, + { model: "Art", query: { plant: "Corvus corax" } }, + { model: "Post", query: { authorization: true } }, + { model: "BirdPost", query: { authorization: true } }, + ]); +}); diff --git a/test/server-lifecycle.test.js b/test/server-lifecycle.test.js new file mode 100644 index 0000000..0c5dc2a --- /dev/null +++ b/test/server-lifecycle.test.js @@ -0,0 +1,51 @@ +const assert = require("node:assert/strict"); +const http = require("node:http"); +const { once } = require("node:events"); +const { test } = require("node:test"); + +const { startServer } = require("../server"); + +test("server startup connects dependencies and graceful shutdown closes both layers", async () => { + let connectCalls = 0; + let disconnectCalls = 0; + const runtime = { + async connect() { + connectCalls += 1; + return { ready: true }; + }, + async disconnect() { + disconnectCalls += 1; + }, + }; + const app = { + listen(port, host, callback) { + const server = http.createServer((req, res) => { + res.statusCode = 204; + res.end(); + }); + + return server.listen(port, host, callback); + }, + }; + const logger = { + info() {}, + error() {}, + }; + + const { server, shutdown } = startServer({ + appInstance: app, + runtimeInstance: runtime, + port: 0, + host: "127.0.0.1", + logger, + registerSignalHandlers: false, + }); + + await once(server, "listening"); + await new Promise((resolve) => setImmediate(resolve)); + await shutdown("test"); + + assert.equal(connectCalls, 1); + assert.equal(disconnectCalls, 1); + assert.equal(server.listening, false); +}); From 39decce8d7e48f133441bfd148d69ee882b16e8e Mon Sep 17 00:00:00 2001 From: LQ458 Date: Wed, 22 Jul 2026 15:58:38 +0800 Subject: [PATCH 03/31] fix: enforce authorization boundaries --- .gitignore | 3 +- app.js | 970 ++++++++++++++++++------------ client/src/App.test.js | 5 + client/src/UserContext.js | 24 + client/src/components/Admin.js | 4 +- docs/operations/phase-2-report.md | 130 ++++ models/uploadPolicy.js | 130 ++++ models/uploadart.js | 56 +- models/uploadfile.js | 59 +- models/user.js | 2 + runtime.js | 31 + test/auth-boundary.test.js | 423 +++++++++++++ 12 files changed, 1414 insertions(+), 423 deletions(-) create mode 100644 docs/operations/phase-2-report.md create mode 100644 models/uploadPolicy.js create mode 100644 test/auth-boundary.test.js diff --git a/.gitignore b/.gitignore index e9c5313..ec66536 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,8 @@ yarn-debug.log* yarn-error.log* /public +/uploads /client/public/plantspic /client/public/compressed /client/public -/node_modules \ No newline at end of file +/node_modules diff --git a/app.js b/app.js index 70c5298..3b6c97d 100644 --- a/app.js +++ b/app.js @@ -24,6 +24,14 @@ const path = require("path"); const fs = require("fs").promises; // Node.js file system module with promise support const bcrypt = require("bcrypt"); const multer = require("multer"); +const { + MAX_IMAGE_FILE_SIZE, + createImageFilename, + ensureUploadTempDirectory, + imageFileFilter, + getUploadErrorResponse, + validateImageFiles, +} = require("./models/uploadPolicy"); const featureList = require("./models/featureList"); const creationBottom = require("./models/creationBottom"); const FeatureHome = require("./models/featureHome"); @@ -39,7 +47,6 @@ const runtime = createRuntime({ env: process.env, logger: console, }); -const redisClient = runtime.redisClient; app.use(compression()); //gzip compression for faster speed app.use(bodyParser.json()); @@ -74,22 +81,28 @@ app.get("/health/ready", (req, res) => { const storage = multer.diskStorage({ destination: function (req, file, cb) { - cb(null, "./public/uploads/"); // Set the destination folder for uploaded files + try { + cb(null, ensureUploadTempDirectory()); + } catch (error) { + cb(error); + } }, filename: function (req, file, cb) { - const fileExtension = file.originalname.split(".").pop().toLowerCase(); - const filename = - req.body.plant.replace(/\s+/g, "") + - "-" + - crypto.randomBytes(16).toString("hex").slice(0, 16) + - "." + - fileExtension; // Generate a unique filename - cb(null, filename); + try { + cb(null, createImageFilename(file.mimetype)); + } catch (error) { + cb(error); + } }, }); // Set up multer storage const upload = multer({ storage: storage, + limits: { + fileSize: MAX_IMAGE_FILE_SIZE, + files: 2, + }, + fileFilter: imageFileFilter, }).fields([ { name: "pic", maxCount: 1 }, { name: "art", maxCount: 1 }, @@ -97,47 +110,206 @@ const upload = multer({ const globalUpload = multer({}); +function getUploadedFiles(files) { + if (Array.isArray(files)) { + return files; + } + + return Object.values(files || {}).flat(); +} + +async function cleanupFiles(filePaths) { + await Promise.all( + filePaths.map((filePath) => + fs.unlink(filePath).catch((error) => { + if (error.code !== "ENOENT") { + console.warn("Unable to clean up uploaded file"); + } + }), + ), + ); +} + +function uploadWithCleanup(req, res, next) { + upload(req, res, async (error) => { + if (error) { + await cleanupFiles(getUploadedFiles(req.files).map((file) => file.path)); + return next(error); + } + + return next(); + }); +} + +async function cleanupRecords(model, records) { + await Promise.all( + records.map((record) => + model.deleteOne({ _id: record._id }).catch(() => { + console.warn("Unable to roll back uploaded record"); + }), + ), + ); +} + +function getAuthorizationToken(authorization) { + if (typeof authorization !== "string") { + return null; + } + + const value = authorization.trim(); + if (!value || value === "undefined") { + return null; + } + + const bearerMatch = value.match(/^Bearer\s+(.+)$/i); + const token = bearerMatch ? bearerMatch[1].trim() : value; + + return token || null; +} + async function verifyToken(req, res, next) { - const token = req.headers["authorization"]; + const token = getAuthorizationToken(req.headers["authorization"]); + req.sessionStoreUnavailable = false; - if (!token || token === "undefined") { + if (!token) { req.user = null; + req.token = null; return next(); } + let decoded; try { - const decoded = await new Promise((resolve, reject) => { - jwt.verify(token, process.env.secret, (err, decoded) => { - if (err) { - if (err.message === "jwt expired") { - // If the JWT is expired, delete it from Redis - redisClient.del(decoded.username); + decoded = jwt.verify(token, process.env.secret); + } catch (err) { + if (err.name === "TokenExpiredError") { + const expiredToken = jwt.decode(token); + if (expiredToken?.username) { + try { + const sessionToken = await runtime.getSessionToken( + expiredToken.username, + ); + if (sessionToken === token) { + await runtime.deleteSession(expiredToken.username); } - return reject(err); + } catch (_) { + console.warn("Unable to clear an expired session cache entry"); } - resolve(decoded); - }); - }); + } + } - req.user = decoded; + req.user = null; + req.token = null; + return next(); + } - const redisToken = await redisClient.get(req.user.username); + try { + const sessionToken = await runtime.getSessionToken(decoded.username); - if (redisToken === token) { - req.token = token; - } else if (redisToken) { - req.token = redisToken; + if (sessionToken !== token) { + req.user = null; + req.token = null; + return next(); } - - return next(); - } catch (err) { + } catch (_) { req.user = null; + req.token = null; + req.sessionStoreUnavailable = true; return next(); } + + req.user = decoded; + req.token = token; + return next(); +} + +function requireAuth(req, res, next) { + return verifyToken(req, res, async (error) => { + if (error) { + return next(error); + } + + if (req.sessionStoreUnavailable) { + return sessionStoreUnavailable(res); + } + + if (!req.user?.username) { + if ( + getAuthorizationToken(req.headers["authorization"]) && + !runtime.isSessionStoreReady() + ) { + return sessionStoreUnavailable(res); + } + + return res.status(401).json({ + success: false, + message: "Authentication required", + }); + } + + try { + const currentUser = await User.findOne( + { username: req.user.username }, + { username: 1, admin: 1 }, + ); + + if (!currentUser) { + return res.status(401).json({ + success: false, + message: "Authentication required", + }); + } + + req.authenticatedUser = { + username: currentUser.username, + admin: Boolean(currentUser.admin), + }; + return next(); + } catch (authError) { + return next(authError); + } + }); +} + +function requireAdmin(req, res, next) { + return requireAuth(req, res, (error) => { + if (error) { + return next(error); + } + + if (!req.authenticatedUser?.admin) { + return res.status(403).json({ + success: false, + message: "Administrator access required", + }); + } + + return next(); + }); +} + +const USER_PUBLIC_PROJECTION = { username: 1, admin: 1 }; + +function toPublicUser(user) { + return { + _id: user._id, + username: user.username, + admin: Boolean(user.admin), + }; +} + +function sessionStoreUnavailable(res) { + return res.status(503).json({ + success: false, + message: "Authentication service unavailable", + }); } app.post("/login", verifyToken, async (req, res) => { var passwordMatch = false; + if (req.sessionStoreUnavailable) { + return sessionStoreUnavailable(res); + } + if (req.user) { return res.json({ success: true, @@ -148,7 +320,7 @@ app.post("/login", verifyToken, async (req, res) => { } const { username, password } = req.body; - const user = await User.findOne({ username }); + const user = await User.findOne({ username }).select("+password"); if (user) { passwordMatch = await bcrypt.compare(password, user.password); @@ -157,26 +329,46 @@ app.post("/login", verifyToken, async (req, res) => { } if (passwordMatch) { + if (!runtime.isSessionStoreReady()) { + return sessionStoreUnavailable(res); + } + var token = jwt.sign( { username: user.username, admin: user.admin }, process.env.secret, { expiresIn: "180d" }, ); - const redisToken = await redisClient.get(username); + try { + const redisToken = await runtime.getSessionToken(username); + let hasUsableSession = false; - if (redisToken) { - token = redisToken; - } else { - await redisClient.set(username, token, redis.print); - } + if (redisToken) { + try { + const sessionPayload = jwt.verify(redisToken, process.env.secret); + hasUsableSession = sessionPayload?.username === username; + } catch (_) { + hasUsableSession = false; + } + } - return res.json({ - success: true, - message: "Login successful", - user: (({ username, admin }) => ({ username, admin }))(user), - token, - }); + if (hasUsableSession) { + token = redisToken; + } else { + await runtime.deleteSession(username); + await runtime.setSessionToken(username, token); + } + + return res.json({ + success: true, + message: "Login successful", + user: (({ username, admin }) => ({ username, admin }))(user), + token, + }); + } catch (_) { + console.warn("Unable to create a login session"); + return sessionStoreUnavailable(res); + } } else { return res.json({ success: false, @@ -186,6 +378,10 @@ app.post("/login", verifyToken, async (req, res) => { }); app.get("/refresh", verifyToken, async (req, res) => { + if (req.sessionStoreUnavailable) { + return sessionStoreUnavailable(res); + } + if (!req.user) { return res.json({ success: false, message: "Token not valid" }); } @@ -199,22 +395,35 @@ app.get("/refresh", verifyToken, async (req, res) => { app.post("/logout", verifyToken, async (req, res) => { if (!req.user) { + if ( + req.sessionStoreUnavailable || + (getAuthorizationToken(req.headers["authorization"]) && + !runtime.isSessionStoreReady()) + ) { + return sessionStoreUnavailable(res); + } + return res.json({ success: true, message: "Logout successful" }); } - await redisClient.del(req.user?.username); + try { + await runtime.deleteSession(req.user.username); + } catch (_) { + console.warn("Unable to remove login session"); + return sessionStoreUnavailable(res); + } return res.json({ success: true, message: "Logout successful" }); }); -app.post("/adminView", async (req, res) => { +app.post("/adminView", requireAdmin, async (req, res) => { const resultPlant = await Post.findOne({ latinName: req.body.search }); const resultPics = await Pic.find({ plant: req.body.search }); const resultArts = await Art.find({ plant: req.body.search }); res.json({ success: true, resultPlant, resultPics, resultArts }); }); -app.post("/makeFeatured", async (req, res) => { +app.post("/makeFeatured", requireAdmin, async (req, res) => { var homeScreenFeatureList = await featureList.findOne({ name: "homescreenFeature", }); @@ -247,7 +456,6 @@ app.post("/register", async (req, res) => { await User.create({ username, password: password, - originalPassword: req.body.password, admin: false, }); return res.json({ success: true, message: "Register successful" }); @@ -259,14 +467,14 @@ app.post("/register", async (req, res) => { } }); -app.get("/adminDataGet", async (req, res) => { +app.get("/adminDataGet", requireAdmin, async (req, res) => { const plants = await Post.find({ authorization: true }); - const users = await User.find(); + const users = await User.find({}, USER_PUBLIC_PROJECTION); console.log("message recieved"); - res.json({ success: true, plants, users }); + res.json({ success: true, plants, users: users.map(toPublicUser) }); }); -app.post("/adminToggle", async (req, res) => { +app.post("/adminToggle", requireAdmin, async (req, res) => { const user = await User.findOne({ username: req.body.username }); user.admin = !user.admin; await user.save(); @@ -362,7 +570,7 @@ app.get("/userInfoGlossaryBird", verifyToken, async (req, res) => { console.log("123") }); -app.post("/edit", async function (req, res) { +app.post("/edit", requireAuth, async function (req, res) { try { res.json({ success: true }); } catch (error) { @@ -370,7 +578,7 @@ app.post("/edit", async function (req, res) { } }); -app.post("/newPostAuth", verifyToken, upload, async (req, res, type) => { +app.post("/newPostAuth", requireAdmin, uploadWithCleanup, async (req, res, type) => { if (req.body.decision) { const post = await Post.findOne({ _id: req.body.id }); post.authorization = true; @@ -382,7 +590,7 @@ app.post("/newPostAuth", verifyToken, upload, async (req, res, type) => { res.json({ success: true }); }); -app.post("/newBirdPostAuth", verifyToken, upload, async (req, res, type) => { +app.post("/newBirdPostAuth", requireAdmin, uploadWithCleanup, async (req, res, type) => { if (req.body.decision) { const post = await BirdPost.findOne({ _id: req.body.id }); post.authorization = true; @@ -393,7 +601,7 @@ app.post("/newBirdPostAuth", verifyToken, upload, async (req, res, type) => { res.json({ success: true }); }); -app.post("/featureToHome", verifyToken, async (req, res) => { +app.post("/featureToHome", requireAdmin, async (req, res) => { try { const { picId, artId, isCreation } = req.body; @@ -465,11 +673,25 @@ app.post("/featureToHome", verifyToken, async (req, res) => { } }); -app.post("/uploadCreation", verifyToken, upload, async (req, res) => { +app.post("/uploadCreation", requireAuth, uploadWithCleanup, async (req, res) => { + const inputFiles = []; + const outputFiles = []; + let creation; + try { const { body, files } = req; - const inputFiles = []; - const outputFolderPath = "public/plantspic/"; + const uploadedFiles = getUploadedFiles(files); + inputFiles.push(...uploadedFiles.map((file) => file.path)); + outputFiles.push( + ...uploadedFiles.map((file) => + path.join(__dirname, "public", "plantspic", file.filename), + ), + ); + const outputFolderPath = `${path.join( + __dirname, + "public", + "plantspic", + )}${path.sep}`; const plant = await Post.findOne({ latinName: body.plant }); if (!plant) { @@ -486,6 +708,8 @@ app.post("/uploadCreation", verifyToken, upload, async (req, res) => { }); } + await validateImageFiles([files.pic[0], files.art[0]]); + // Get or create pic code var picCode = await Code.findOne({ type: "crePic" }); if (!picCode) { @@ -500,10 +724,6 @@ app.post("/uploadCreation", verifyToken, upload, async (req, res) => { await artCode.save(); } - // Add files to compression queue - inputFiles.push("./public/uploads/" + files.pic[0].filename); - inputFiles.push("./public/uploads/" + files.art[0].filename); - // Compress images and wait for results const compressionResults = await imageCompressor.compressImages( inputFiles, @@ -522,13 +742,13 @@ app.post("/uploadCreation", verifyToken, upload, async (req, res) => { const artPath = path.join("/plantspic/", files.art[0].filename); // Create creation entry - const creation = new creationBottom({ + creation = new creationBottom({ auth: false, plant: body.plant, art: artPath, pic: picPath, date: new Date().toISOString(), - creator: req.user?.username || "admin", + creator: req.authenticatedUser.username, artist: body.artist, photographer: body.photographer, photoDate: body.photoDate, @@ -557,36 +777,36 @@ app.post("/uploadCreation", verifyToken, upload, async (req, res) => { ), ]); - // Clean up original files after successful compression - await Promise.all( - inputFiles.map((file) => - fs.unlink(file).catch((err) => { - console.warn( - `Warning: Could not delete temporary file ${file}:`, - err, - ); - }), - ), - ); - - res.json({ + return res.json({ success: true, message: "Creation uploaded successfully", creation, }); } catch (error) { - console.error("Error in uploadCreation:", error); - res.status(500).json({ + if (creation?._id) { + await cleanupRecords(creationBottom, [creation]); + } + await cleanupFiles(outputFiles); + + const uploadError = getUploadErrorResponse(error); + if (uploadError) { + return res.status(uploadError.status).json(uploadError.body); + } + + console.error("Creation upload failed"); + return res.status(500).json({ success: false, - message: error.message || "Failed to upload creation", + message: "Unable to process creation upload", }); + } finally { + await cleanupFiles(inputFiles); } }); app.post( "/uploadPlant", + requireAuth, globalUpload.none(), - verifyToken, async function (req, res) { try { var username = "admin"; @@ -648,8 +868,8 @@ app.post( app.post( "/uploadBird", + requireAuth, globalUpload.none(), - verifyToken, async function (req, res) { try { var username = "admin"; @@ -714,7 +934,7 @@ app.post( }, ); -app.post("/newCreationAuth", verifyToken, upload, async (req, res) => { +app.post("/newCreationAuth", requireAdmin, uploadWithCleanup, async (req, res) => { try { if (req.body.decision) { const post = await creationBottom.findOne({ _id: req.body.id }); @@ -747,10 +967,23 @@ app.post("/newCreationAuth", verifyToken, upload, async (req, res) => { } }); -app.post("/uploadArt", artmiddleware, verifyToken, async function (req, res) { +app.post("/uploadArt", requireAuth, artmiddleware, async function (req, res) { const inputFiles = []; - const outputFolderPath = "public/plantspic/"; - const compressedOutputFolderPath = "public/plantspic/"; + const outputFiles = []; + const createdArts = []; + const uploadedFiles = getUploadedFiles(req.files); + const outputFolderPath = `${path.join( + __dirname, + "public", + "plantspic", + )}${path.sep}`; + + inputFiles.push(...uploadedFiles.map((file) => file.path)); + outputFiles.push( + ...uploadedFiles.map((file) => + path.join(__dirname, "public", "plantspic", file.filename), + ), + ); try { const plant = await Post.findOne({ latinName: req.body.plant }); @@ -760,20 +993,34 @@ app.post("/uploadArt", artmiddleware, verifyToken, async function (req, res) { .json({ success: false, message: "Plant not found" }); } - var code = await Code.findOne({ type: "art" }); - if (!code) { - console.log("No code found, creating a new one with count 0"); - code = new Code({ type: "art", count: 0 }); - await code.save(); - } + if (uploadedFiles.length >= 1) { + await validateImageFiles(uploadedFiles); + + // Compress images and wait for results + const compressionResults = await imageCompressor.compressImages( + inputFiles, + outputFolderPath, + ); + + // Check if any compression failed + const failedFiles = compressionResults.filter( + (result) => !result.success, + ); + if ( + compressionResults.length !== inputFiles.length || + failedFiles.length > 0 + ) { + throw new Error("Image compression failed"); + } - var username = req.user?.username || "admin"; + var code = await Code.findOne({ type: "art" }); + if (!code) { + code = new Code({ type: "art", count: 0 }); + await code.save(); + } - if (req.files && req.files.length >= 1) { - for (const file of req.files) { - const filePath = "./public/uploads/" + file.filename; - inputFiles.push(filePath); - let newCount = (code.count + 1).toString().padStart(4, "0"); + for (const file of uploadedFiles) { + const newCount = (code.count + 1).toString().padStart(4, "0"); const art = new Art({ plant: req.body.plant, location: req.body.artLocation, @@ -783,8 +1030,7 @@ app.post("/uploadArt", artmiddleware, verifyToken, async function (req, res) { }); await art.save(); - console.log("Art saved with code:", newCount); - + createdArts.push(art); code = await Code.findOneAndUpdate( { type: "art" }, { $inc: { count: 1 } }, @@ -792,54 +1038,65 @@ app.post("/uploadArt", artmiddleware, verifyToken, async function (req, res) { ); } - // Compress images and wait for results - const compressionResults = await imageCompressor.compressImages( - inputFiles, - outputFolderPath, - ); - - // Check if any compression failed - const failedFiles = compressionResults.filter( - (result) => !result.success, - ); - if (failedFiles.length > 0) { - throw new Error( - `Failed to compress files: ${failedFiles.map((f) => f.file).join(", ")}`, - ); - } - - // Clean up original files after successful compression - await Promise.all( - inputFiles.map((file) => - fs.unlink(file).catch((err) => { - console.warn( - `Warning: Could not delete temporary file ${file}:`, - err, - ); - }), - ), - ); - - res.json({ success: true, message: "Art uploaded successfully" }); + return res.json({ success: true, message: "Art uploaded successfully" }); } else { - res.status(400).json({ success: false, message: "No files uploaded" }); + return res + .status(400) + .json({ success: false, message: "No files uploaded" }); } } catch (error) { - console.error("An error occurred:", error); - res + await cleanupRecords(Art, createdArts); + await cleanupFiles(outputFiles); + + const uploadError = getUploadErrorResponse(error); + if (uploadError) { + return res.status(uploadError.status).json(uploadError.body); + } + + console.error("Artwork upload failed"); + return res .status(500) - .json({ success: false, message: "Error processing request" }); + .json({ success: false, message: "Unable to process image upload" }); + } finally { + await cleanupFiles(inputFiles); } }); app.post( "/uploadPic", + requireAuth, uploadmiddleware, - verifyToken, async function (req, res) { const inputFiles = []; - const outputFolderPath = "public/plantspic/"; - const smallerCompressedFolderPath = "public/compressed/plantspic/"; + const outputFiles = []; + const createdPics = []; + const uploadedFiles = getUploadedFiles(req.files); + const outputFolderPath = `${path.join( + __dirname, + "public", + "plantspic", + )}${path.sep}`; + const smallerCompressedFolderPath = `${path.join( + __dirname, + "public", + "compressed", + "plantspic", + )}${path.sep}`; + + inputFiles.push(...uploadedFiles.map((file) => file.path)); + outputFiles.push( + ...uploadedFiles.flatMap((file) => [ + path.join(__dirname, "public", "plantspic", file.filename), + path.join( + __dirname, + "public", + "compressed", + "plantspic", + file.filename, + ), + ]), + ); + try { const plant = await Post.findOne({ latinName: req.body.picEnglishName }); @@ -847,143 +1104,135 @@ app.post( return res.status(404).send("Plant not found"); } - var code = await Code.findOne({ type: "pic" }); - if (!code) { - console.log("No pic code found, creating a new one with count 0"); - code = new Code({ type: "pic", count: 0 }); - await code.save(); - } - - var username = "admin"; - if (req.user) { - username = req.user?.username; - } - - if (req.files && req.files.length >= 1) { - for (const file of req.files) { - const filePath = "./public/uploads/" + file.filename; - inputFiles.push(filePath); - let newCount = (code.count + 1).toString().padStart(4, "0"); - const pic = new Pic({ - plant: req.body.picEnglishName, - art: req.body.picArt, - modifiedBy: username, - season: req.body.picSeason, - takenBy: req.body.picPhotographer, - location: req.body.picSetting, - path: "/plantspic/" + file.filename, - time: req.body.month, - featured: false, - code: newCount, - }); - - await pic.save(); - code = await Code.findOneAndUpdate( - { type: "pic" }, - { $inc: { count: 1 } }, - { new: true }, - ); - console.log("pic saved with code: " + newCount); - } + if (uploadedFiles.length >= 1) { + await validateImageFiles(uploadedFiles); - // for (const file of req.files) { - // const filePath = "./public/uploads/" + file.filename; - // inputFiles.push(filePath); - // let newCount = (code.count + 1).toString().padStart(4, "0"); - // const pic = new Pic({ - // plant: req.body.picEnglishName, - // art: req.body.picArt, - // modifiedBy: username, - // season: req.body.picSeason, - // takenBy: req.body.picPhotographer, - // location: req.body.picSetting, - // path: "/compressed/plantspic/" + file.filename, - // time: req.body.month, - // featured: false, - // code: newCount, - // }); - - // await pic.save(); - // code = await Code.findOneAndUpdate( - // { type: "pic" }, - // { $inc: { count: 1 } }, - // { new: true }, - // ); - // console.log("pic saved with code: " + newCount); - // } - - // Compress images and wait for results const compressionResults = await imageCompressor.compressImages( inputFiles, outputFolderPath, 5, - 90 + 90, ); - - - - // Check if any compression failed - var failedFiles = compressionResults.filter( + let failedFiles = compressionResults.filter( (result) => !result.success, ); - if (failedFiles.length > 0) { - throw new Error( - `Failed to compress files: ${failedFiles.map((f) => f.file).join(", ")}`, - ); + if ( + compressionResults.length !== inputFiles.length || + failedFiles.length > 0 + ) { + throw new Error("Image compression failed"); } - const smallerCompressionResults = await imageCompressor.compressImages( inputFiles, smallerCompressedFolderPath, 9, - 70 + 70, ); - // Check if any compression failed failedFiles = smallerCompressionResults.filter( (result) => !result.success, ); + if ( + smallerCompressionResults.length !== inputFiles.length || + failedFiles.length > 0 + ) { + throw new Error("Image compression failed"); + } - if (failedFiles.length > 0) { - throw new Error( - `Failed to compress files: ${failedFiles.map((f) => f.file).join(", ")}`, - ); + var code = await Code.findOne({ type: "pic" }); + if (!code) { + code = new Code({ type: "pic", count: 0 }); + await code.save(); } - // Clean up original files after successful compression - await Promise.all( - inputFiles.map((file) => - fs.unlink(file).catch((err) => { - console.warn( - `Warning: Could not delete temporary file ${file}:`, - err, - ); - }), - ), - ); + for (const file of uploadedFiles) { + const newCount = (code.count + 1).toString().padStart(4, "0"); + const pic = new Pic({ + plant: req.body.picEnglishName, + art: req.body.picArt, + modifiedBy: req.authenticatedUser.username, + season: req.body.picSeason, + takenBy: req.body.picPhotographer, + location: req.body.picSetting, + path: "/plantspic/" + file.filename, + time: req.body.month, + featured: false, + code: newCount, + }); + + await pic.save(); + createdPics.push(pic); + code = await Code.findOneAndUpdate( + { type: "pic" }, + { $inc: { count: 1 } }, + { new: true }, + ); + } - res.json({ success: true, message: "Picture uploaded successfully" }); + return res.json({ + success: true, + message: "Picture uploaded successfully", + }); } else { - res.status(400).json({ success: false, message: "No files uploaded" }); + return res + .status(400) + .json({ success: false, message: "No files uploaded" }); } } catch (error) { - console.error("Error in uploadPic:", error); - res + await cleanupRecords(Pic, createdPics); + await cleanupFiles(outputFiles); + + const uploadError = getUploadErrorResponse(error); + if (uploadError) { + return res.status(uploadError.status).json(uploadError.body); + } + + console.error("Picture upload failed"); + return res .status(500) - .json({ success: false, message: "Error processing request" }); + .json({ success: false, message: "Unable to process image upload" }); + } finally { + await cleanupFiles(inputFiles); } }, ); app.post( "/uploadBirdPic", + requireAuth, uploadmiddleware, - verifyToken, async function (req, res) { const inputFiles = []; - const outputFolderPath = "public/plantspic/"; - const smallerCompressedFolderPath = "public/compressed/plantspic/"; + const outputFiles = []; + const createdPics = []; + const uploadedFiles = getUploadedFiles(req.files); + const outputFolderPath = `${path.join( + __dirname, + "public", + "plantspic", + )}${path.sep}`; + const smallerCompressedFolderPath = `${path.join( + __dirname, + "public", + "compressed", + "plantspic", + )}${path.sep}`; + + inputFiles.push(...uploadedFiles.map((file) => file.path)); + outputFiles.push( + ...uploadedFiles.flatMap((file) => [ + path.join(__dirname, "public", "plantspic", file.filename), + path.join( + __dirname, + "public", + "compressed", + "plantspic", + file.filename, + ), + ]), + ); + try { const plant = await BirdPost.findOne({ latinName: req.body.picEnglishName }); @@ -991,132 +1240,97 @@ app.post( return res.status(404).send("Plant not found"); } - var code = await Code.findOne({ type: "pic" }); - if (!code) { - console.log("No pic code found, creating a new one with count 0"); - code = new Code({ type: "pic", count: 0 }); - await code.save(); - } - - var username = "admin"; - if (req.user) { - username = req.user?.username; - } - - if (req.files && req.files.length >= 1) { - for (const file of req.files) { - const filePath = "./public/uploads/" + file.filename; - inputFiles.push(filePath); - let newCount = (code.count + 1).toString().padStart(4, "0"); - const pic = new Pic({ - plant: req.body.picEnglishName, - art: req.body.picArt, - modifiedBy: username, - season: req.body.picSeason.replace("-","").replace(" ",""), - takenBy: req.body.picPhotographer, - location: req.body.picSetting, - path: "/plantspic/" + file.filename, - time: req.body.month, - featured: false, - code: newCount, - dbType: 'bird' - }); - - await pic.save(); - code = await Code.findOneAndUpdate( - { type: "pic" }, - { $inc: { count: 1 } }, - { new: true }, - ); - console.log("pic saved with code: " + newCount); - } + if (uploadedFiles.length >= 1) { + await validateImageFiles(uploadedFiles); - // for (const file of req.files) { - // const filePath = "./public/uploads/" + file.filename; - // inputFiles.push(filePath); - // let newCount = (code.count + 1).toString().padStart(4, "0"); - // const pic = new Pic({ - // plant: req.body.picEnglishName, - // art: req.body.picArt, - // modifiedBy: username, - // season: req.body.picSeason, - // takenBy: req.body.picPhotographer, - // location: req.body.picSetting, - // path: "/compressed/plantspic/" + file.filename, - // time: req.body.month, - // featured: false, - // code: newCount, - // }); - - // await pic.save(); - // code = await Code.findOneAndUpdate( - // { type: "pic" }, - // { $inc: { count: 1 } }, - // { new: true }, - // ); - // console.log("pic saved with code: " + newCount); - // } - - // Compress images and wait for results const compressionResults = await imageCompressor.compressImages( inputFiles, outputFolderPath, 5, - 90 + 90, ); - - - - // Check if any compression failed - var failedFiles = compressionResults.filter( + let failedFiles = compressionResults.filter( (result) => !result.success, ); - if (failedFiles.length > 0) { - throw new Error( - `Failed to compress files: ${failedFiles.map((f) => f.file).join(", ")}`, - ); + if ( + compressionResults.length !== inputFiles.length || + failedFiles.length > 0 + ) { + throw new Error("Image compression failed"); } - const smallerCompressionResults = await imageCompressor.compressImages( inputFiles, smallerCompressedFolderPath, 9, - 70 + 70, ); - // Check if any compression failed failedFiles = smallerCompressionResults.filter( (result) => !result.success, ); + if ( + smallerCompressionResults.length !== inputFiles.length || + failedFiles.length > 0 + ) { + throw new Error("Image compression failed"); + } - if (failedFiles.length > 0) { - throw new Error( - `Failed to compress files: ${failedFiles.map((f) => f.file).join(", ")}`, - ); + var code = await Code.findOne({ type: "pic" }); + if (!code) { + code = new Code({ type: "pic", count: 0 }); + await code.save(); } - // Clean up original files after successful compression - await Promise.all( - inputFiles.map((file) => - fs.unlink(file).catch((err) => { - console.warn( - `Warning: Could not delete temporary file ${file}:`, - err, - ); - }), - ), - ); + for (const file of uploadedFiles) { + const newCount = (code.count + 1).toString().padStart(4, "0"); + const pic = new Pic({ + plant: req.body.picEnglishName, + art: req.body.picArt, + modifiedBy: req.authenticatedUser.username, + season: req.body.picSeason.replace("-", "").replace(" ", ""), + takenBy: req.body.picPhotographer, + location: req.body.picSetting, + path: "/plantspic/" + file.filename, + time: req.body.month, + featured: false, + code: newCount, + dbType: "bird", + }); + + await pic.save(); + createdPics.push(pic); + code = await Code.findOneAndUpdate( + { type: "pic" }, + { $inc: { count: 1 } }, + { new: true }, + ); + } - res.json({ success: true, message: "Picture uploaded successfully" }); + return res.json({ + success: true, + message: "Picture uploaded successfully", + }); } else { - res.status(400).json({ success: false, message: "No files uploaded" }); + return res + .status(400) + .json({ success: false, message: "No files uploaded" }); } } catch (error) { - console.error("Error in uploadPic:", error); - res + await cleanupRecords(Pic, createdPics); + await cleanupFiles(outputFiles); + + const uploadError = getUploadErrorResponse(error); + if (uploadError) { + return res.status(uploadError.status).json(uploadError.body); + } + + console.error("Bird picture upload failed"); + return res .status(500) - .json({ success: false, message: "Error processing request" }); + .json({ success: false, message: "Unable to process image upload" }); + } finally { + await cleanupFiles(inputFiles); } }, ); @@ -1187,29 +1401,28 @@ app.get("/numOfBirds", async (req, res) => { res.json({ numOfPlants }); }); -app.get("/adminInfo", verifyToken, async (req, res) => { - if (req.user) { - const users = await User.find(); - const posts = await Post.find({ authorization: true }); - const pics = await Pic.find(); - res.json({ - success: true, - users, - posts, - username: req.user?.username, - admin: req.user?.admin, - pics, - }); - } else { - res.json({ admin: false, success: true }); - } +app.get("/adminInfo", requireAdmin, async (req, res) => { + const users = await User.find({}, USER_PUBLIC_PROJECTION); + const posts = await Post.find({ authorization: true }); + const pics = await Pic.find(); + res.json({ + success: true, + users: users.map(toPublicUser), + posts, + username: req.authenticatedUser.username, + admin: true, + pics, + }); }); -app.post("/adminDeleteUser", async (req, res) => { +app.post("/adminDeleteUser", requireAdmin, async (req, res) => { try { const userToDelete = await User.findById(req.body.id); await User.deleteOne({ _id: userToDelete._id }); + await runtime.deleteSession(userToDelete.username).catch(() => { + console.warn("Unable to remove deleted user session"); + }); res.status(200).json({ message: "User deleted successfully" }); } catch (error) { @@ -1218,7 +1431,7 @@ app.post("/adminDeleteUser", async (req, res) => { } }); -app.post("/adminMakeAdminUser", async (req, res) => { +app.post("/adminMakeAdminUser", requireAdmin, async (req, res) => { try { var user = await User.findById(req.body.id); @@ -1267,7 +1480,7 @@ app.post("/getPics", async (req, res) => { } }); -app.post("/makePicFeatured", async (req, res) => { +app.post("/makePicFeatured", requireAdmin, async (req, res) => { const pic = await Pic.findById(req.body.id); pic.featured = !pic.featured; await pic.save(); @@ -1292,7 +1505,7 @@ app.post("/makePicFeatured", async (req, res) => { // // res.sendFile(path.join(__dirname, "/guide.html")); // }); -app.post("/updateText", verifyToken, async function (req, res) { +app.post("/updateText", requireAuth, async function (req, res) { const editTextRequest = new EditTextRequest({ latinName: req.body.latinName, chineseName: req.body.chineseName, @@ -1315,7 +1528,7 @@ app.post("/updateText", verifyToken, async function (req, res) { console.log("updated plant successfully") }); -app.post("/birdUpdateText", verifyToken, async function (req, res) { +app.post("/birdUpdateText", requireAuth, async function (req, res) { const editTextRequest = new BirdEditTextRequest({ latinName: req.body.latinName, chineseName: req.body.chineseName, @@ -1350,15 +1563,7 @@ app.post("/birdUpdateText", verifyToken, async function (req, res) { res.json({ success: true }); }); -app.get("/adminAuth", verifyToken, async function (req, res) { - var admin; - - if (req.user?.admin) { - admin = req.user?.admin; - } else { - admin = null; - } - +app.get("/adminAuth", requireAdmin, async function (req, res) { const plantAuthPosts = await EditTextRequest.find(); const birdAuthPosts = await BirdEditTextRequest.find(); const authPosts = plantAuthPosts.concat(birdAuthPosts); @@ -1371,14 +1576,14 @@ app.get("/adminAuth", verifyToken, async function (req, res) { res.json({ success: true, - admin: admin, + admin: true, authPosts, newAuthPosts, newCreationEntries, }); }); -app.put("/handleBirdEditDecision", verifyToken, async function (req, res) { +app.put("/handleBirdEditDecision", requireAdmin, async function (req, res) { try { // await BirdEditTextRequest.findByIdAndDelete("6864f3e21f4a1cd7556aabc1") // await EditTextRequest.findByIdAndDelete("6864f3e21f4a1cd7556aabc1") @@ -1509,7 +1714,7 @@ app.put("/handleBirdEditDecision", verifyToken, async function (req, res) { } }); -app.put("/handleEditDecision", verifyToken, async function (req, res) { +app.put("/handleEditDecision", requireAdmin, async function (req, res) { try { const request = await EditTextRequest.findById(req.body.id); @@ -1638,8 +1843,8 @@ app.put("/handleEditDecision", verifyToken, async function (req, res) { app.post( "/uploadFeatureSingle", - verifyToken, - upload, + requireAdmin, + uploadWithCleanup, async function (req, res) { const plant = req.body.plant.substring(1); const temp = await Pic.findOne({ path: req.body.path }); @@ -1661,8 +1866,8 @@ app.post( app.post( "/uploadFeatureArtSingle", - verifyToken, - upload, + requireAdmin, + uploadWithCleanup, async function (req, res) { const plant = req.body.plant.substring(1); const temp = await Art.findOne({ path: req.body.path }); @@ -1682,13 +1887,13 @@ app.post( }, ); -app.get("/uploadHome", upload, async (req, res) => { +app.get("/uploadHome", uploadWithCleanup, async (req, res) => { const entries = await FeatureHome.find(); res.json({ success: true, entries }); }); -app.post("/unFeatureHome", upload, async (req, res) => { +app.post("/unFeatureHome", requireAdmin, uploadWithCleanup, async (req, res) => { try { // 使用 _id 来精确删除特定的 feature await FeatureHome.deleteOne({ _id: req.body.id }); @@ -1700,12 +1905,12 @@ app.post("/unFeatureHome", upload, async (req, res) => { } }); -app.get("/uploadCreation", upload, async (req, res) => { +app.get("/uploadCreation", uploadWithCleanup, async (req, res) => { const temp = await creationBottom.find({ auth: true }); res.json({ temp, success: true }); }); -app.post("/unFeatureCreation", upload, async (req, res) => { +app.post("/unFeatureCreation", requireAdmin, uploadWithCleanup, async (req, res) => { try { const art = await creationBottom.findOne({ _id: req.body.temp }); const artPath = art.art; @@ -1763,7 +1968,7 @@ app.get("/db2AltBird", async (req, res) => { res.json({ success: true, pic }); }); -app.post("/editPageDelete", async (req, res) => { +app.post("/editPageDelete", requireAdmin, async (req, res) => { try { const pic = await Pic.findOne({ _id: req.body.id }); await fs.unlink(path.join(__dirname, "public", pic.path)); @@ -1775,7 +1980,7 @@ app.post("/editPageDelete", async (req, res) => { } }); -app.delete("/editPageDeletePlant", async (req, res) => { +app.delete("/editPageDeletePlant", requireAdmin, async (req, res) => { try { const plant = (await Post.findOne({ _id: req.body.id })).latinName; @@ -1831,6 +2036,11 @@ app.use(function (err, req, res, next) { return next(err); } + const uploadError = getUploadErrorResponse(err); + if (uploadError) { + return res.status(uploadError.status).json(uploadError.body); + } + console.error("Unhandled request error"); return res.status(500).send("Something broke!"); }); diff --git a/client/src/App.test.js b/client/src/App.test.js index e7f332a..fc09002 100644 --- a/client/src/App.test.js +++ b/client/src/App.test.js @@ -8,6 +8,11 @@ jest.mock("react-player", () => () => null); jest.mock("axios", () => ({ defaults: {}, + interceptors: { + request: { + use: jest.fn(), + }, + }, get: jest.fn().mockResolvedValue({ data: {} }), post: jest.fn(), })); diff --git a/client/src/UserContext.js b/client/src/UserContext.js index ad96f32..79e468b 100644 --- a/client/src/UserContext.js +++ b/client/src/UserContext.js @@ -4,6 +4,27 @@ import { Map, fromJS } from "immutable"; import axios from "axios"; axios.defaults.withCredentials = true; +axios.interceptors.request.use((config) => { + const token = localStorage.getItem("askanything"); + const apiBaseUrl = process.env.REACT_APP_Source_URL; + const normalizedApiBaseUrl = apiBaseUrl?.replace(/\/$/, ""); + const isApiRequest = + normalizedApiBaseUrl && + (config.url === normalizedApiBaseUrl || + config.url?.startsWith(`${normalizedApiBaseUrl}/`)); + + if (!token || !isApiRequest) { + return config; + } + + const headers = config.headers || {}; + if (!headers.authorization && !headers.Authorization) { + headers.Authorization = `Bearer ${token}`; + } + config.headers = headers; + + return config; +}); const userReducer = (state, action) => { switch (action.type) { @@ -56,6 +77,8 @@ export const UserProvider = ({ children }) => { { username, password, + }, + { headers: { authorization: token, }, @@ -85,6 +108,7 @@ export const UserProvider = ({ children }) => { localStorage.removeItem("askanything"); const response = await axios.post( `${process.env.REACT_APP_Source_URL}/logout`, + {}, { headers: { authorization: token, diff --git a/client/src/components/Admin.js b/client/src/components/Admin.js index 269a678..bdda9d7 100644 --- a/client/src/components/Admin.js +++ b/client/src/components/Admin.js @@ -97,14 +97,12 @@ const Admin = ({ handleAdminPreview }) => { Username - Password {users.map((user) => ( - + {user.username} - {user.password} ))} diff --git a/docs/operations/phase-2-report.md b/docs/operations/phase-2-report.md new file mode 100644 index 0000000..2ab2fcb --- /dev/null +++ b/docs/operations/phase-2-report.md @@ -0,0 +1,130 @@ +# Phase 2 report — authorization boundaries and upload validation + +Date: 2026-07-22. + +## What changed and why + +- Kept optional identity attachment for the existing public personalized + endpoints, while adding explicit `requireAuth` (401) and `requireAdmin` + (403) middleware for protected actions. +- `requireAdmin` checks the current User record rather than trusting the + long-lived `admin` value embedded in a JWT. It now protects management data, + role changes, approvals, recommendations, and destructive routes. +- A token must also match the active Redis session for its username. A Redis + lookup/delete failure produces a 503 response for login, logout, and + protected actions rather than being treated as an invalid token or a + successful logout. +- Content creation, edit submissions, and media uploads require a valid login. + Authentication now precedes multipart processing, so anonymous requests do + not reach Multer's disk-writing middleware. +- Both legacy bare JWTs and standard `Bearer` JWTs are accepted. The client + adds a Bearer token only to requests sent to its configured API base URL; + existing explicit raw-token calls remain compatible. +- New registrations no longer write `originalPassword`. Both password fields + are excluded by default at the schema level, and administrative user lists + use an explicit public projection plus a DTO that contains only `_id`, + `username`, and `admin`. +- Added one shared upload policy for JPEG, PNG, and WebP. It writes only + server-generated filenames to a private temporary directory, verifies image + content with Sharp, caps each image at 16 MiB, and caps a multipart batch at + 20 files. Failure paths remove temporary/derived files and return generic + 400/413 errors without exposing internals. +- Removed the password column from the administrator UI, since it is no + longer a supported response field. + +## Authorization matrix + +| Access | Route groups | +| --- | --- | +| Public or optional identity | Health, login/register/logout/refresh, public catalog/search/detail/count routes, public media/creation reads, and optional user-info/glossary responses. | +| Authenticated contributor | `/edit`, plant/bird/creation submissions, picture/art uploads, and plant/bird edit-request submissions. | +| Current administrator | Admin data/view/role routes; plant, bird, creation, and edit approvals; feature/recommendation management; and user, media, creation, and plant deletion routes. | + +The source route handlers are the authoritative mapping. Every protected +operation that writes, approves, changes a role, recommends content, or +removes data now has an explicit guard before its state-changing middleware. + +## Upload-limit evidence + +Metric: Production image-file distribution used for the server-side per-file +limit. + +Exact definition: Aggregate file sizes beneath the live `plantspic` and +`compressed/plantspic` directories; this is not a count of distinct uploaded +images or a measure of upload requests. + +Value: 1,229 files; min 32,626 B; median 2,035,805 B; p95 3,130,654 B; max +11,050,829 B; total 2,150,940,650 B. Extension counts: PNG 1,150, JPG 79. +The effective Nginx `client_max_body_size` was 500m. + +Time window: Current live filesystem snapshot collected 2026-07-22. + +Source: Read-only production filesystem aggregate and effective Nginx +configuration. + +Query/calculation: File sizes were sorted numerically; median used the middle +observation and p95 used the ceiling of 95 percent of the ordered file count. +Extension counts used lower-cased filename suffixes only. + +Bot/internal-traffic filtering: Not applicable. + +Collected on: 2026-07-22. + +Confidence: Direct for the enumerated directories at collection time. + +Limitations: The directories include derivative files, so this is not a +distinct-image metric. It does not establish historical batch sizes. The 20 +file batch cap is an operational guard, not a claim about past user behavior. +The 16 MiB per-file limit exceeds the observed maximum and remains well below +the configured proxy limit; it needs staging upload sampling before deployment. + +## Checks and health results + +- Backend: 11 tests pass. Seven focused authorization/upload cases cover + anonymous 401s, non-admin 403, raw and Bearer JWT compatibility, current + database admin state, session-store failures, secret-field exclusion, and + invalid multipart cleanup. +- Frontend: 2 existing route smoke tests pass after extending the Axios mock + for the shared request interceptor. +- Unified local verification: `npm run verify` passed, including the + production client build. +- Production HTTPS: a read-only request returned HTTP 200. No new code was + deployed. + +## Production impact + +No deployment, restart, database write, Redis write, media write, Nginx +change, backup change, or credential/configuration permission change occurred. +The production service remains on its prior commit. The only production reads +in this phase were aggregate media/Nginx evidence and an HTTPS status check. + +## Unresolved risks + +- Existing database documents may still contain `originalPassword`; this phase + prevents new writes and responses, but deliberately does not run the + separately approved cleanup migration. +- Redis session continuity and temporary-upload directory permissions still + need an isolated staging check before rollout. +- The final upload limits need an isolated staging upload test before any + production rollout. Historical batch-size telemetry is unavailable. +- A failed upload can leave a gap in a sequential display/code value; it does + not delete or overwrite existing media, but the sequence is not transactional. +- The code does not yet prevent an administrator from deleting or demoting the + last administrator; that needs a separately specified policy and test. +- There is no staging environment or verified backup restore. Existing CRA, + CSS nesting, ESLint, Browserslist, Babel-preset, and Jest open-handle + warnings remain unchanged. + +## Deployment and rollback + +No deployment is proposed for this phase. + +If separately approved, deploy this one commit only after a verified MongoDB +dump, media manifest, environment/configuration backup, and staging smoke test +using an admin and a standard user. Verify public reads, 401/403 responses, +admin workflows, valid JPEG/PNG/WebP uploads, and invalid/oversized upload +rejection. Do not overwrite `public` or `.env`. + +Rollback is code-only: return to the previous commit, rebuild the client, and +perform an authorized service restart. No migration or media change was made, +so no database or file restoration is required for this phase. diff --git a/models/uploadPolicy.js b/models/uploadPolicy.js new file mode 100644 index 0000000..65bd74f --- /dev/null +++ b/models/uploadPolicy.js @@ -0,0 +1,130 @@ +const multer = require("multer"); +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); +const sharp = require("sharp"); + +// This stays below the effective Nginx request limit and above the largest +// image currently stored in production. It is a per-file limit, not a claim +// about typical uploads. +const MAX_IMAGE_FILE_SIZE = 16 * 1024 * 1024; +const MAX_FILES_PER_BATCH = 20; +const SUPPORTED_IMAGE_TYPES = new Set([ + "image/jpeg", + "image/png", + "image/webp", +]); +const SUPPORTED_IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp"]); +const IMAGE_EXTENSION_BY_MIME_TYPE = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", +}; +const IMAGE_FORMAT_BY_MIME_TYPE = { + "image/jpeg": "jpeg", + "image/png": "png", + "image/webp": "webp", +}; +const UPLOAD_TEMP_DIR = path.join(__dirname, "..", "uploads"); + +function imageFileFilter(req, file, callback) { + const extension = path.extname(file.originalname || "").toLowerCase(); + const isSupported = + SUPPORTED_IMAGE_TYPES.has(file.mimetype) && + SUPPORTED_IMAGE_EXTENSIONS.has(extension); + + if (isSupported) { + return callback(null, true); + } + + return callback(new multer.MulterError("LIMIT_UNEXPECTED_FILE", file.fieldname)); +} + +function createImageFilename(mimetype) { + const extension = IMAGE_EXTENSION_BY_MIME_TYPE[mimetype]; + if (!extension) { + throw new Error("Unsupported image type"); + } + + return `${crypto.randomBytes(16).toString("hex")}${extension}`; +} + +function ensureUploadTempDirectory() { + fs.mkdirSync(UPLOAD_TEMP_DIR, { recursive: true }); + return UPLOAD_TEMP_DIR; +} + +function invalidImageError() { + const error = new Error("Invalid image data"); + error.code = "INVALID_IMAGE"; + return error; +} + +async function validateImageFiles(files) { + try { + await Promise.all( + files.map(async (file) => { + const metadata = await sharp(file.path).metadata(); + if (metadata.format !== IMAGE_FORMAT_BY_MIME_TYPE[file.mimetype]) { + throw invalidImageError(); + } + }), + ); + } catch (_) { + throw invalidImageError(); + } +} + +function getUploadErrorResponse(error) { + if (error?.code === "INVALID_IMAGE") { + return { + status: 400, + body: { + success: false, + message: "Invalid image upload", + }, + }; + } + + if (!(error instanceof multer.MulterError)) { + return null; + } + + if (error.code === "LIMIT_FILE_SIZE") { + return { + status: 413, + body: { + success: false, + message: "Image file is too large", + }, + }; + } + + return { + status: 400, + body: { + success: false, + message: "Invalid image upload", + }, + }; +} + +const batchImageUploadOptions = { + limits: { + fileSize: MAX_IMAGE_FILE_SIZE, + files: MAX_FILES_PER_BATCH, + }, + fileFilter: imageFileFilter, +}; + +module.exports = { + MAX_FILES_PER_BATCH, + MAX_IMAGE_FILE_SIZE, + UPLOAD_TEMP_DIR, + batchImageUploadOptions, + createImageFilename, + ensureUploadTempDirectory, + getUploadErrorResponse, + imageFileFilter, + validateImageFiles, +}; diff --git a/models/uploadart.js b/models/uploadart.js index 38b88c2..9879a43 100644 --- a/models/uploadart.js +++ b/models/uploadart.js @@ -1,36 +1,56 @@ const multer = require("multer"); -const crypto = require("crypto"); +const fs = require("fs").promises; +const { + MAX_FILES_PER_BATCH, + batchImageUploadOptions, + createImageFilename, + ensureUploadTempDirectory, + getUploadErrorResponse, +} = require("./uploadPolicy"); var fileCount = 0; // Initialize a variable to keep track of the file count const storage = multer.diskStorage({ destination: function (req, file, cb) { - cb(null, "./public/uploads/"); // Set the destination folder for uploaded files + try { + cb(null, ensureUploadTempDirectory()); + } catch (error) { + cb(error); + } }, filename: function (req, file, cb) { - const fileExtension = file.originalname.split(".").pop().toLowerCase(); - const filename = - req.body.plant + - "-" + - crypto.randomBytes(16).toString("hex").slice(0, 16) + - "." + - fileExtension; // Generate a unique filename - cb(null, filename); + try { + cb(null, createImageFilename(file.mimetype)); + } catch (error) { + cb(error); + } }, }); const upload = multer({ storage: storage, -}).array("files"); + ...batchImageUploadOptions, +}).array("files", MAX_FILES_PER_BATCH); const middleware = function (req, res, next) { - upload(req, res, function (err) { - if (err instanceof multer.MulterError) { - // A Multer error occurred during file upload - return res.status(500).json({ message: "File upload error" }); - } else if (err) { - // An unknown error occurred during file upload - return res.status(500).json({ message: "Unknown error occurred" }); + upload(req, res, async function (err) { + if (err) { + await Promise.all( + (req.files || []).map((file) => fs.unlink(file.path).catch(() => {})), + ); + } + + const uploadError = getUploadErrorResponse(err); + if (uploadError) { + return res.status(uploadError.status).json(uploadError.body); + } + + if (err) { + console.error("Image upload middleware failed"); + return res.status(500).json({ + success: false, + message: "Unable to process image upload", + }); } // Set the file count to the number of uploaded files at the moment diff --git a/models/uploadfile.js b/models/uploadfile.js index fd8c16d..9879a43 100644 --- a/models/uploadfile.js +++ b/models/uploadfile.js @@ -1,39 +1,56 @@ const multer = require("multer"); -const crypto = require("crypto"); +const fs = require("fs").promises; +const { + MAX_FILES_PER_BATCH, + batchImageUploadOptions, + createImageFilename, + ensureUploadTempDirectory, + getUploadErrorResponse, +} = require("./uploadPolicy"); var fileCount = 0; // Initialize a variable to keep track of the file count const storage = multer.diskStorage({ destination: function (req, file, cb) { - cb(null, "./public/uploads/"); // Set the destination folder for uploaded files + try { + cb(null, ensureUploadTempDirectory()); + } catch (error) { + cb(error); + } }, filename: function (req, file, cb) { - const fileExtension = file.originalname.split(".").pop().toLowerCase(); - const filename = - req.body.picEnglishName + - "-" + - crypto.randomBytes(16).toString("hex").slice(0, 16) + - "." + - fileExtension; // Generate a unique filename - cb(null, filename); + try { + cb(null, createImageFilename(file.mimetype)); + } catch (error) { + cb(error); + } }, }); const upload = multer({ storage: storage, -}).array("files"); + ...batchImageUploadOptions, +}).array("files", MAX_FILES_PER_BATCH); const middleware = function (req, res, next) { - upload(req, res, function (err) { - if (err instanceof multer.MulterError) { - // A Multer error occurred during file upload - return res.status(500).json({ message: "File upload error" }); - } else if (err) { - console.log("err", err); - // An unknown error occurred during file upload - return res - .status(500) - .json({ message: "Unknown error occurred", error: err }); + upload(req, res, async function (err) { + if (err) { + await Promise.all( + (req.files || []).map((file) => fs.unlink(file.path).catch(() => {})), + ); + } + + const uploadError = getUploadErrorResponse(err); + if (uploadError) { + return res.status(uploadError.status).json(uploadError.body); + } + + if (err) { + console.error("Image upload middleware failed"); + return res.status(500).json({ + success: false, + message: "Unable to process image upload", + }); } // Set the file count to the number of uploaded files at the moment diff --git a/models/user.js b/models/user.js index 7530d25..55932a5 100644 --- a/models/user.js +++ b/models/user.js @@ -9,10 +9,12 @@ const userSchema = new Schema({ }, originalPassword: { type: String, + select: false, }, password: { type: String, required: true, + select: false, }, admin: { type: Boolean, diff --git a/runtime.js b/runtime.js index b53a2a9..0fc8bef 100644 --- a/runtime.js +++ b/runtime.js @@ -109,6 +109,33 @@ function createRuntime({ return getReadiness(); }; + const getSessionToken = async (username) => { + if (!redisClient.isReady) { + return null; + } + + return redisClient.get(username); + }; + + const setSessionToken = async (username, token) => { + if (!redisClient.isReady) { + throw new Error("Redis session store is unavailable"); + } + + await redisClient.set(username, token); + }; + + const deleteSession = async (username) => { + if (!redisClient.isReady) { + throw new Error("Redis session store is unavailable"); + } + + await redisClient.del(username); + return true; + }; + + const isSessionStoreReady = () => redisClient.isReady; + const disconnect = async () => { const tasks = []; @@ -140,8 +167,12 @@ function createRuntime({ return { redisClient, connect, + deleteSession, disconnect, + getSessionToken, getReadiness, + setSessionToken, + isSessionStoreReady, }; } diff --git a/test/auth-boundary.test.js b/test/auth-boundary.test.js new file mode 100644 index 0000000..454a2b5 --- /dev/null +++ b/test/auth-boundary.test.js @@ -0,0 +1,423 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const http = require("node:http"); +const path = require("node:path"); +const { after, before, beforeEach, test } = require("node:test"); +const { once } = require("node:events"); +const jwt = require("jsonwebtoken"); +const bcrypt = require("bcrypt"); + +process.env.secret = "phase-two-test-secret"; + +const User = require("../models/user"); +const Post = require("../models/post"); +const Pic = require("../models/pic"); +const { UPLOAD_TEMP_DIR } = require("../models/uploadPolicy"); +const { app, runtime } = require("../app"); + +let server; +let sessionStoreAvailable = true; +const sessions = new Map(); +const originals = { + userFind: User.find, + userFindOne: User.findOne, + postFind: Post.find, + picFind: Pic.find, + deleteSession: runtime.deleteSession, + getSessionToken: runtime.getSessionToken, + isSessionStoreReady: runtime.isSessionStoreReady, + setSessionToken: runtime.setSessionToken, +}; + +function request( + pathname, + { method = "GET", body, payload: suppliedPayload, headers = {} } = {}, +) { + const payload = suppliedPayload ?? (body ? JSON.stringify(body) : null); + const payloadLength = Buffer.isBuffer(payload) + ? payload.length + : Buffer.byteLength(payload || ""); + + return new Promise((resolve, reject) => { + const req = http.request( + { + hostname: "127.0.0.1", + port: server.address().port, + path: pathname, + method, + headers: { + ...headers, + ...(payload + ? { + ...(suppliedPayload ? {} : { "content-type": "application/json" }), + "content-length": payloadLength, + } + : {}), + }, + }, + (res) => { + let responseBody = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + responseBody += chunk; + }); + res.on("end", () => { + let parsedBody = null; + if (responseBody) { + try { + parsedBody = JSON.parse(responseBody); + } catch (_) { + parsedBody = responseBody; + } + } + + resolve({ + status: res.statusCode, + body: parsedBody, + }); + }); + }, + ); + + req.on("error", reject); + if (payload) { + req.write(payload); + } + req.end(); + }); +} + +before(async () => { + runtime.getSessionToken = async (username) => + sessionStoreAvailable ? sessions.get(username) || null : null; + runtime.setSessionToken = async (username, token) => { + if (!sessionStoreAvailable) { + throw new Error("session store unavailable"); + } + sessions.set(username, token); + }; + runtime.deleteSession = async (username) => { + if (!sessionStoreAvailable) { + throw new Error("session store unavailable"); + } + return sessions.delete(username); + }; + runtime.isSessionStoreReady = () => sessionStoreAvailable; + + server = app.listen(0, "127.0.0.1"); + await once(server, "listening"); +}); + +beforeEach(() => { + sessionStoreAvailable = true; + sessions.clear(); + User.find = originals.userFind; + User.findOne = originals.userFindOne; + Post.find = originals.postFind; + Pic.find = originals.picFind; +}); + +after(async () => { + User.find = originals.userFind; + User.findOne = originals.userFindOne; + Post.find = originals.postFind; + Pic.find = originals.picFind; + runtime.deleteSession = originals.deleteSession; + runtime.getSessionToken = originals.getSessionToken; + runtime.isSessionStoreReady = originals.isSessionStoreReady; + runtime.setSessionToken = originals.setSessionToken; + + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +}); + +test("protected write and administration routes reject requests without a token", async () => { + const responses = await Promise.all([ + request("/uploadPic", { method: "POST", body: {} }), + request("/newPostAuth", { method: "POST", body: {} }), + request("/adminDataGet"), + request("/editPageDelete", { method: "POST", body: {} }), + ]); + + for (const response of responses) { + assert.equal(response.status, 401); + assert.deepEqual(response.body, { + success: false, + message: "Authentication required", + }); + } +}); + +test("a non-admin token cannot access administration data", async () => { + User.findOne = async () => ({ username: "member", admin: false }); + const token = jwt.sign( + { username: "member", admin: false }, + process.env.secret, + ); + sessions.set("member", token); + + const response = await request("/adminInfo", { + headers: { authorization: `Bearer ${token}` }, + }); + + assert.equal(response.status, 403); + assert.deepEqual(response.body, { + success: false, + message: "Administrator access required", + }); +}); + +test("raw and Bearer JWTs work while admin user responses exclude secret fields", async () => { + const memberToken = jwt.sign( + { username: "member", admin: false }, + process.env.secret, + ); + sessions.set("member", memberToken); + const refresh = await request("/refresh", { + headers: { authorization: memberToken }, + }); + assert.equal(refresh.status, 200); + assert.equal(refresh.body.success, true); + assert.equal(refresh.body.user.username, "member"); + + User.findOne = async (query, projection) => { + assert.deepEqual(query, { username: "admin" }); + assert.deepEqual(projection, { username: 1, admin: 1 }); + return { username: "admin", admin: true }; + }; + User.find = async (query, projection) => { + assert.deepEqual(query, {}); + assert.deepEqual(projection, { username: 1, admin: 1 }); + return [ + { + _id: "user-1", + username: "member", + admin: false, + password: "bcrypt-hash", + originalPassword: "plaintext", + }, + ]; + }; + Post.find = async () => []; + Pic.find = async () => []; + + const adminToken = jwt.sign( + { username: "admin", admin: false }, + process.env.secret, + ); + sessions.set("admin", adminToken); + const response = await request("/adminInfo", { + headers: { authorization: `Bearer ${adminToken}` }, + }); + + assert.equal(response.status, 200); + assert.deepEqual(response.body.users, [ + { _id: "user-1", username: "member", admin: false }, + ]); + assert.equal("password" in response.body.users[0], false); + assert.equal("originalPassword" in response.body.users[0], false); +}); + +test("logout and removed accounts lose protected access", async () => { + const token = jwt.sign( + { username: "member", admin: false }, + process.env.secret, + ); + sessions.set("member", token); + + const logout = await request("/logout", { + method: "POST", + headers: { authorization: token }, + }); + assert.equal(logout.status, 200); + assert.equal(sessions.has("member"), false); + + const afterLogout = await request("/updateText", { + method: "POST", + body: {}, + headers: { authorization: token }, + }); + assert.equal(afterLogout.status, 401); + + sessions.set("member", token); + User.findOne = async () => null; + const removedAccount = await request("/updateText", { + method: "POST", + body: {}, + headers: { authorization: token }, + }); + assert.equal(removedAccount.status, 401); +}); + +test("login and logout report a session-store outage", async () => { + const password = "correct-password"; + const passwordHash = await bcrypt.hash(password, 4); + User.findOne = () => ({ + select: async () => ({ + username: "member", + admin: false, + password: passwordHash, + }), + }); + sessionStoreAvailable = false; + + const login = await request("/login", { + method: "POST", + body: { username: "member", password }, + }); + assert.equal(login.status, 503); + assert.deepEqual(login.body, { + success: false, + message: "Authentication service unavailable", + }); + + const token = jwt.sign( + { username: "member", admin: false }, + process.env.secret, + ); + const logout = await request("/logout", { + method: "POST", + headers: { authorization: token }, + }); + assert.equal(logout.status, 503); + assert.deepEqual(logout.body, { + success: false, + message: "Authentication service unavailable", + }); + + const protectedWrite = await request("/updateText", { + method: "POST", + body: {}, + headers: { authorization: token }, + }); + assert.equal(protectedWrite.status, 503); + assert.deepEqual(protectedWrite.body, { + success: false, + message: "Authentication service unavailable", + }); +}); + +test("spoofed multipart images are rejected and removed without path traversal", async () => { + const token = jwt.sign( + { username: "member", admin: false }, + process.env.secret, + ); + sessions.set("member", token); + User.findOne = async () => ({ username: "member", admin: false }); + Post.findOne = async () => ({ latinName: "Acer rubrum" }); + + const uploadDirectoryExisted = await fs + .stat(UPLOAD_TEMP_DIR) + .then(() => true) + .catch(() => false); + await fs.mkdir(UPLOAD_TEMP_DIR, { recursive: true }); + const before = new Set(await fs.readdir(UPLOAD_TEMP_DIR)); + const boundary = "biphflora-test-boundary"; + const payload = Buffer.from( + `--${boundary}\r\n` + + 'Content-Disposition: form-data; name="picEnglishName"\r\n\r\n' + + "Acer rubrum\r\n" + + `--${boundary}\r\n` + + 'Content-Disposition: form-data; name="files"; filename="../../escape.png"\r\n' + + "Content-Type: image/png\r\n\r\n" + + "not-a-real-image\r\n" + + `--${boundary}--\r\n`, + ); + + const response = await request("/uploadPic", { + method: "POST", + payload, + headers: { + authorization: `Bearer ${token}`, + "content-type": `multipart/form-data; boundary=${boundary}`, + }, + }); + + assert.equal(response.status, 400); + assert.deepEqual(response.body, { + success: false, + message: "Invalid image upload", + }); + + Post.findOne = async () => null; + const validPng = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScL0QgAAAABJRU5ErkJggg==", + "base64", + ); + const missingPlantPayload = Buffer.concat([ + Buffer.from( + `--${boundary}\r\n` + + 'Content-Disposition: form-data; name="picEnglishName"\r\n\r\n' + + "Missing plant\r\n" + + `--${boundary}\r\n` + + 'Content-Disposition: form-data; name="files"; filename="../../missing.png"\r\n' + + "Content-Type: image/png\r\n\r\n", + ), + validPng, + Buffer.from(`\r\n--${boundary}--\r\n`), + ]); + const missingPlant = await request("/uploadPic", { + method: "POST", + payload: missingPlantPayload, + headers: { + authorization: `Bearer ${token}`, + "content-type": `multipart/form-data; boundary=${boundary}`, + }, + }); + assert.equal(missingPlant.status, 404); + assert.equal(missingPlant.body, "Plant not found"); + + assert.deepEqual(new Set(await fs.readdir(UPLOAD_TEMP_DIR)), before); + assert.equal( + await fs + .stat(path.join(process.cwd(), "escape.png")) + .then(() => true) + .catch(() => false), + false, + ); + + if (!uploadDirectoryExisted) { + await fs.rmdir(UPLOAD_TEMP_DIR); + } +}); + +test("session lookup errors do not masquerade as an invalid token or successful logout", async () => { + const token = jwt.sign( + { username: "member", admin: false }, + process.env.secret, + ); + const getSessionToken = runtime.getSessionToken; + runtime.getSessionToken = async () => { + throw new Error("transient Redis read failure"); + }; + + try { + const responses = await Promise.all([ + request("/login", { + method: "POST", + body: { username: "member", password: "ignored" }, + headers: { authorization: `Bearer ${token}` }, + }), + request("/logout", { + method: "POST", + headers: { authorization: token }, + }), + request("/updateText", { + method: "POST", + body: {}, + headers: { authorization: token }, + }), + ]); + + for (const response of responses) { + assert.equal(response.status, 503); + assert.deepEqual(response.body, { + success: false, + message: "Authentication service unavailable", + }); + } + } finally { + runtime.getSessionToken = getSessionToken; + } +}); From c02dbd239679e017b734de93daa893079744fe3f Mon Sep 17 00:00:00 2001 From: LQ458 Date: Wed, 22 Jul 2026 16:12:09 +0800 Subject: [PATCH 04/31] refactor: separate application modules --- app.js | 218 ++---------------------------- client/src/UserContext.js | 25 +--- client/src/api/http.js | 26 ++++ docs/operations/phase-3-report.md | 53 ++++++++ middleware/auth.js | 154 +++++++++++++++++++++ routes/catalog.js | 73 ++++++++++ 6 files changed, 317 insertions(+), 232 deletions(-) create mode 100644 client/src/api/http.js create mode 100644 docs/operations/phase-3-report.md create mode 100644 middleware/auth.js create mode 100644 routes/catalog.js diff --git a/app.js b/app.js index 3b6c97d..e1ca318 100644 --- a/app.js +++ b/app.js @@ -36,6 +36,8 @@ const featureList = require("./models/featureList"); const creationBottom = require("./models/creationBottom"); const FeatureHome = require("./models/featureHome"); const { createRuntime } = require("./runtime"); +const { createAuthMiddleware } = require("./middleware/auth"); +const catalogRouter = require("./routes/catalog"); const Code = require("./models/code"); const birdPost = require('./models/birdPost') const crypto = require("crypto"); @@ -47,6 +49,13 @@ const runtime = createRuntime({ env: process.env, logger: console, }); +const { + getAuthorizationToken, + requireAdmin, + requireAuth, + sessionStoreUnavailable, + verifyToken, +} = createAuthMiddleware({ User, jwt, runtime }); app.use(compression()); //gzip compression for faster speed app.use(bodyParser.json()); @@ -151,142 +160,6 @@ async function cleanupRecords(model, records) { ); } -function getAuthorizationToken(authorization) { - if (typeof authorization !== "string") { - return null; - } - - const value = authorization.trim(); - if (!value || value === "undefined") { - return null; - } - - const bearerMatch = value.match(/^Bearer\s+(.+)$/i); - const token = bearerMatch ? bearerMatch[1].trim() : value; - - return token || null; -} - -async function verifyToken(req, res, next) { - const token = getAuthorizationToken(req.headers["authorization"]); - req.sessionStoreUnavailable = false; - - if (!token) { - req.user = null; - req.token = null; - return next(); - } - - let decoded; - try { - decoded = jwt.verify(token, process.env.secret); - } catch (err) { - if (err.name === "TokenExpiredError") { - const expiredToken = jwt.decode(token); - if (expiredToken?.username) { - try { - const sessionToken = await runtime.getSessionToken( - expiredToken.username, - ); - if (sessionToken === token) { - await runtime.deleteSession(expiredToken.username); - } - } catch (_) { - console.warn("Unable to clear an expired session cache entry"); - } - } - } - - req.user = null; - req.token = null; - return next(); - } - - try { - const sessionToken = await runtime.getSessionToken(decoded.username); - - if (sessionToken !== token) { - req.user = null; - req.token = null; - return next(); - } - } catch (_) { - req.user = null; - req.token = null; - req.sessionStoreUnavailable = true; - return next(); - } - - req.user = decoded; - req.token = token; - return next(); -} - -function requireAuth(req, res, next) { - return verifyToken(req, res, async (error) => { - if (error) { - return next(error); - } - - if (req.sessionStoreUnavailable) { - return sessionStoreUnavailable(res); - } - - if (!req.user?.username) { - if ( - getAuthorizationToken(req.headers["authorization"]) && - !runtime.isSessionStoreReady() - ) { - return sessionStoreUnavailable(res); - } - - return res.status(401).json({ - success: false, - message: "Authentication required", - }); - } - - try { - const currentUser = await User.findOne( - { username: req.user.username }, - { username: 1, admin: 1 }, - ); - - if (!currentUser) { - return res.status(401).json({ - success: false, - message: "Authentication required", - }); - } - - req.authenticatedUser = { - username: currentUser.username, - admin: Boolean(currentUser.admin), - }; - return next(); - } catch (authError) { - return next(authError); - } - }); -} - -function requireAdmin(req, res, next) { - return requireAuth(req, res, (error) => { - if (error) { - return next(error); - } - - if (!req.authenticatedUser?.admin) { - return res.status(403).json({ - success: false, - message: "Administrator access required", - }); - } - - return next(); - }); -} - const USER_PUBLIC_PROJECTION = { username: 1, admin: 1 }; function toPublicUser(user) { @@ -297,13 +170,6 @@ function toPublicUser(user) { }; } -function sessionStoreUnavailable(res) { - return res.status(503).json({ - success: false, - message: "Authentication service unavailable", - }); -} - app.post("/login", verifyToken, async (req, res) => { var passwordMatch = false; if (req.sessionStoreUnavailable) { @@ -1335,71 +1201,7 @@ app.post( }, ); -app.get("/searchNames", async (req, res) => { - const posts = await Post.find({ authorization: true }); - if (!posts) { - res.json({ success: false, returnNames: [], numOfPlants: 0 }); - } - res.json({ success: true, returnNames: posts, numOfPlants: posts.length }); -}); - - - -app.get("/searchBirdNames", async (req, res) => { - const birdPosts = await BirdPost.find({ authorization: true }); - if (!birdPosts) { - res.json({ success: false, returnNames: [], numOfPlants: 0 }); - } - res.json({ success: true, returnNames: birdPosts, numOfPlants: birdPosts.length }); -}); - -app.post("/syncPlantInfo", async (req, res) => { - const resultPost = await Post.find({ - latinName: req.body.postName, - authorization: true, - }); - const photographs = await Pic.find({ - art: "photography", - plant: req.body.postName, - }); - const arts = await Art.find({ plant: req.body.postName }); - res.json({ resultPost: resultPost, photographs: photographs, arts: arts }); -}); - -app.post("/syncBirdInfo", async (req, res) => { - const resultPost = await BirdPost.find({ - latinName: req.body.postName, - authorization: true, - }); - const photographs = await Pic.find({ - art: "photography", - plant: req.body.postName, - }); - const arts = await Art.find({ plant: req.body.postName }); - res.json({ resultPost: resultPost, photographs: photographs, arts: arts }); -}); - -app.get("/numOfPlants", async (req, res) => { - const posts = await Post.find({ authorization: true }); - var numOfPlants = 0; - - posts.forEach((post) => { - numOfPlants++; - }); - - res.json({ numOfPlants }); -}); - -app.get("/numOfBirds", async (req, res) => { - const posts = await BirdPost.find({ authorization: true }); - var numOfPlants = 0; - - posts.forEach((post) => { - numOfPlants++; - }); - - res.json({ numOfPlants }); -}); +app.use(catalogRouter); app.get("/adminInfo", requireAdmin, async (req, res) => { const users = await User.find({}, USER_PUBLIC_PROJECTION); diff --git a/client/src/UserContext.js b/client/src/UserContext.js index 79e468b..a11994e 100644 --- a/client/src/UserContext.js +++ b/client/src/UserContext.js @@ -1,30 +1,7 @@ // UserContext.js import { createContext, useReducer, useEffect } from "react"; import { Map, fromJS } from "immutable"; -import axios from "axios"; - -axios.defaults.withCredentials = true; -axios.interceptors.request.use((config) => { - const token = localStorage.getItem("askanything"); - const apiBaseUrl = process.env.REACT_APP_Source_URL; - const normalizedApiBaseUrl = apiBaseUrl?.replace(/\/$/, ""); - const isApiRequest = - normalizedApiBaseUrl && - (config.url === normalizedApiBaseUrl || - config.url?.startsWith(`${normalizedApiBaseUrl}/`)); - - if (!token || !isApiRequest) { - return config; - } - - const headers = config.headers || {}; - if (!headers.authorization && !headers.Authorization) { - headers.Authorization = `Bearer ${token}`; - } - config.headers = headers; - - return config; -}); +import axios from "./api/http"; const userReducer = (state, action) => { switch (action.type) { diff --git a/client/src/api/http.js b/client/src/api/http.js new file mode 100644 index 0000000..55ac5d6 --- /dev/null +++ b/client/src/api/http.js @@ -0,0 +1,26 @@ +import axios from "axios"; + +axios.defaults.withCredentials = true; +axios.interceptors.request.use((config) => { + const token = localStorage.getItem("askanything"); + const apiBaseUrl = process.env.REACT_APP_Source_URL; + const normalizedApiBaseUrl = apiBaseUrl?.replace(/\/$/, ""); + const isApiRequest = + normalizedApiBaseUrl && + (config.url === normalizedApiBaseUrl || + config.url?.startsWith(`${normalizedApiBaseUrl}/`)); + + if (!token || !isApiRequest) { + return config; + } + + const headers = config.headers || {}; + if (!headers.authorization && !headers.Authorization) { + headers.Authorization = `Bearer ${token}`; + } + config.headers = headers; + + return config; +}); + +export default axios; diff --git a/docs/operations/phase-3-report.md b/docs/operations/phase-3-report.md new file mode 100644 index 0000000..b39e396 --- /dev/null +++ b/docs/operations/phase-3-report.md @@ -0,0 +1,53 @@ +# Phase 3 report — incremental module boundaries + +Date: 2026-07-22. + +## What changed and why + +- Moved token parsing, Redis-session validation, current-user lookup, and + `requireAuth`/`requireAdmin` into `middleware/auth.js`. The Express entry + point now composes those dependencies instead of owning the policy logic. +- Moved the contiguous public catalog lookup block into `routes/catalog.js`: + `GET /searchNames`, `GET /searchBirdNames`, `POST /syncPlantInfo`, + `POST /syncBirdInfo`, `GET /numOfPlants`, and `GET /numOfBirds`. + The router is mounted at the former route position with no URL prefix. +- Moved the shared Axios defaults and request interceptor from `UserContext` + into `client/src/api/http.js`. The same Axios singleton is retained; no + client call sites, headers, relative URLs, or request/response behavior were + mechanically rewritten in this phase. + +## Compatibility evidence + +The existing public-route contract test exercises all six extracted catalog +endpoints, including their methods, response shapes, model queries, and CORS +behavior. The existing authorization tests exercise the extracted middleware's +401, 403, raw/Bearer token, session-store failure, and current-user behavior. +No new broad test suite was added for a code-movement-only change. + +## Checks and health results + +- `npm run test:backend`: 11 tests passed. +- `npm run verify`: backend tests, frontend route smoke tests, and the + production client build passed. +- A read-only public HTTPS check returned HTTP 200. No branch code was + deployed. + +## Production impact + +No production deployment, service restart, database/Redis/media write, Nginx +change, configuration change, or backup action occurred. This is a source-only +refactor; existing database schemas and API routes remain unchanged. + +## Unresolved risks + +- This is the first small extraction only. Admin, content/edit, and media + routes remain in `app.js` for later independently verified steps. +- There is still no isolated staging environment or verified restore test. +- Existing client build warnings remain outside this change. + +## Deployment and rollback + +No deployment is proposed for this phase. If separately approved, stage this +one commit with the normal public-read, contributor, and administrator smoke +tests. Rollback is code-only: return to the previous commit, rebuild, and +perform an authorized restart. No database or media restoration is required. diff --git a/middleware/auth.js b/middleware/auth.js new file mode 100644 index 0000000..7619401 --- /dev/null +++ b/middleware/auth.js @@ -0,0 +1,154 @@ +function createAuthMiddleware({ User, jwt, runtime, logger = console }) { + function getAuthorizationToken(authorization) { + if (typeof authorization !== "string") { + return null; + } + + const value = authorization.trim(); + if (!value || value === "undefined") { + return null; + } + + const bearerMatch = value.match(/^Bearer\s+(.+)$/i); + const token = bearerMatch ? bearerMatch[1].trim() : value; + + return token || null; + } + + function sessionStoreUnavailable(res) { + return res.status(503).json({ + success: false, + message: "Authentication service unavailable", + }); + } + + async function verifyToken(req, res, next) { + const token = getAuthorizationToken(req.headers["authorization"]); + req.sessionStoreUnavailable = false; + + if (!token) { + req.user = null; + req.token = null; + return next(); + } + + let decoded; + try { + decoded = jwt.verify(token, process.env.secret); + } catch (err) { + if (err.name === "TokenExpiredError") { + const expiredToken = jwt.decode(token); + if (expiredToken?.username) { + try { + const sessionToken = await runtime.getSessionToken( + expiredToken.username, + ); + if (sessionToken === token) { + await runtime.deleteSession(expiredToken.username); + } + } catch (_) { + logger.warn("Unable to clear an expired session cache entry"); + } + } + } + + req.user = null; + req.token = null; + return next(); + } + + try { + const sessionToken = await runtime.getSessionToken(decoded.username); + + if (sessionToken !== token) { + req.user = null; + req.token = null; + return next(); + } + } catch (_) { + req.user = null; + req.token = null; + req.sessionStoreUnavailable = true; + return next(); + } + + req.user = decoded; + req.token = token; + return next(); + } + + function requireAuth(req, res, next) { + return verifyToken(req, res, async (error) => { + if (error) { + return next(error); + } + + if (req.sessionStoreUnavailable) { + return sessionStoreUnavailable(res); + } + + if (!req.user?.username) { + if ( + getAuthorizationToken(req.headers["authorization"]) && + !runtime.isSessionStoreReady() + ) { + return sessionStoreUnavailable(res); + } + + return res.status(401).json({ + success: false, + message: "Authentication required", + }); + } + + try { + const currentUser = await User.findOne( + { username: req.user.username }, + { username: 1, admin: 1 }, + ); + + if (!currentUser) { + return res.status(401).json({ + success: false, + message: "Authentication required", + }); + } + + req.authenticatedUser = { + username: currentUser.username, + admin: Boolean(currentUser.admin), + }; + return next(); + } catch (authError) { + return next(authError); + } + }); + } + + function requireAdmin(req, res, next) { + return requireAuth(req, res, (error) => { + if (error) { + return next(error); + } + + if (!req.authenticatedUser?.admin) { + return res.status(403).json({ + success: false, + message: "Administrator access required", + }); + } + + return next(); + }); + } + + return { + getAuthorizationToken, + requireAdmin, + requireAuth, + sessionStoreUnavailable, + verifyToken, + }; +} + +module.exports = { createAuthMiddleware }; diff --git a/routes/catalog.js b/routes/catalog.js new file mode 100644 index 0000000..3294360 --- /dev/null +++ b/routes/catalog.js @@ -0,0 +1,73 @@ +const express = require("express"); +const Post = require("../models/post"); +const BirdPost = require("../models/birdPost"); +const Pic = require("../models/pic"); +const Art = require("../models/art"); + +const router = express.Router(); + +router.get("/searchNames", async (req, res) => { + const posts = await Post.find({ authorization: true }); + if (!posts) { + res.json({ success: false, returnNames: [], numOfPlants: 0 }); + } + res.json({ success: true, returnNames: posts, numOfPlants: posts.length }); +}); + +router.get("/searchBirdNames", async (req, res) => { + const birdPosts = await BirdPost.find({ authorization: true }); + if (!birdPosts) { + res.json({ success: false, returnNames: [], numOfPlants: 0 }); + } + res.json({ success: true, returnNames: birdPosts, numOfPlants: birdPosts.length }); +}); + +router.post("/syncPlantInfo", async (req, res) => { + const resultPost = await Post.find({ + latinName: req.body.postName, + authorization: true, + }); + const photographs = await Pic.find({ + art: "photography", + plant: req.body.postName, + }); + const arts = await Art.find({ plant: req.body.postName }); + res.json({ resultPost: resultPost, photographs: photographs, arts: arts }); +}); + +router.post("/syncBirdInfo", async (req, res) => { + const resultPost = await BirdPost.find({ + latinName: req.body.postName, + authorization: true, + }); + const photographs = await Pic.find({ + art: "photography", + plant: req.body.postName, + }); + const arts = await Art.find({ plant: req.body.postName }); + res.json({ resultPost: resultPost, photographs: photographs, arts: arts }); +}); + +router.get("/numOfPlants", async (req, res) => { + const posts = await Post.find({ authorization: true }); + var numOfPlants = 0; + + posts.forEach((post) => { + numOfPlants++; + }); + + res.json({ numOfPlants }); +}); + +router.get("/numOfBirds", async (req, res) => { + const posts = await BirdPost.find({ authorization: true }); + var numOfPlants = 0; + + posts.forEach((post) => { + numOfPlants++; + }); + + res.json({ numOfPlants }); +}); + +module.exports = router; From 62ed65779b8855783d7c61b44914e5b41d9c45c3 Mon Sep 17 00:00:00 2001 From: LQ458 Date: Wed, 22 Jul 2026 16:32:26 +0800 Subject: [PATCH 05/31] refactor: separate backend service boundaries --- app.js | 426 ++++--------------------------------- routes/auth.js | 147 +++++++++++++ routes/content.js | 196 +++++++++++++++++ services/mediaFiles.js | 55 +++++ test/app.contract.test.js | 110 ++++++++++ test/auth-boundary.test.js | 44 ++++ test/media-files.test.js | 78 +++++++ 7 files changed, 674 insertions(+), 382 deletions(-) create mode 100644 routes/auth.js create mode 100644 routes/content.js create mode 100644 services/mediaFiles.js create mode 100644 test/media-files.test.js diff --git a/app.js b/app.js index e1ca318..2a305a2 100644 --- a/app.js +++ b/app.js @@ -37,7 +37,17 @@ const creationBottom = require("./models/creationBottom"); const FeatureHome = require("./models/featureHome"); const { createRuntime } = require("./runtime"); const { createAuthMiddleware } = require("./middleware/auth"); +const { createAuthRouter } = require("./routes/auth"); const catalogRouter = require("./routes/catalog"); +const { createContentRouter } = require("./routes/content"); +const { + cleanupFiles, + getCompressedPlantMediaDirectory, + getCompressedPlantMediaPath, + getPlantMediaDirectory, + getPlantMediaPath, + getUploadedFiles, +} = require("./services/mediaFiles"); const Code = require("./models/code"); const birdPost = require('./models/birdPost') const crypto = require("crypto"); @@ -119,26 +129,6 @@ const upload = multer({ const globalUpload = multer({}); -function getUploadedFiles(files) { - if (Array.isArray(files)) { - return files; - } - - return Object.values(files || {}).flat(); -} - -async function cleanupFiles(filePaths) { - await Promise.all( - filePaths.map((filePath) => - fs.unlink(filePath).catch((error) => { - if (error.code !== "ENOENT") { - console.warn("Unable to clean up uploaded file"); - } - }), - ), - ); -} - function uploadWithCleanup(req, res, next) { upload(req, res, async (error) => { if (error) { @@ -170,117 +160,17 @@ function toPublicUser(user) { }; } -app.post("/login", verifyToken, async (req, res) => { - var passwordMatch = false; - if (req.sessionStoreUnavailable) { - return sessionStoreUnavailable(res); - } - - if (req.user) { - return res.json({ - success: true, - message: "Already logged in", - user: req.user, - token: req.token, - }); - } - - const { username, password } = req.body; - const user = await User.findOne({ username }).select("+password"); - - if (user) { - passwordMatch = await bcrypt.compare(password, user.password); - } else { - return res.json({ success: false, message: "User not found" }); - } - - if (passwordMatch) { - if (!runtime.isSessionStoreReady()) { - return sessionStoreUnavailable(res); - } - - var token = jwt.sign( - { username: user.username, admin: user.admin }, - process.env.secret, - { expiresIn: "180d" }, - ); - - try { - const redisToken = await runtime.getSessionToken(username); - let hasUsableSession = false; - - if (redisToken) { - try { - const sessionPayload = jwt.verify(redisToken, process.env.secret); - hasUsableSession = sessionPayload?.username === username; - } catch (_) { - hasUsableSession = false; - } - } - - if (hasUsableSession) { - token = redisToken; - } else { - await runtime.deleteSession(username); - await runtime.setSessionToken(username, token); - } - - return res.json({ - success: true, - message: "Login successful", - user: (({ username, admin }) => ({ username, admin }))(user), - token, - }); - } catch (_) { - console.warn("Unable to create a login session"); - return sessionStoreUnavailable(res); - } - } else { - return res.json({ - success: false, - message: "Invalid username or password", - }); - } -}); - -app.get("/refresh", verifyToken, async (req, res) => { - if (req.sessionStoreUnavailable) { - return sessionStoreUnavailable(res); - } - - if (!req.user) { - return res.json({ success: false, message: "Token not valid" }); - } - - return res.json({ - success: true, - message: "Token refreshed", - user: req.user, - }); -}); - -app.post("/logout", verifyToken, async (req, res) => { - if (!req.user) { - if ( - req.sessionStoreUnavailable || - (getAuthorizationToken(req.headers["authorization"]) && - !runtime.isSessionStoreReady()) - ) { - return sessionStoreUnavailable(res); - } - - return res.json({ success: true, message: "Logout successful" }); - } - - try { - await runtime.deleteSession(req.user.username); - } catch (_) { - console.warn("Unable to remove login session"); - return sessionStoreUnavailable(res); - } - - return res.json({ success: true, message: "Logout successful" }); -}); +app.use( + createAuthRouter({ + User, + bcrypt, + getAuthorizationToken, + jwt, + runtime, + sessionStoreUnavailable, + verifyToken, + }), +); app.post("/adminView", requireAdmin, async (req, res) => { const resultPlant = await Post.findOne({ latinName: req.body.search }); @@ -309,29 +199,17 @@ app.post("/makeFeatured", requireAdmin, async (req, res) => { res.json({ success: true }); }); -app.get("/creationDocumentary", async (req, res) => { - const allDisplays = await creationBottom.find({ auth: true }); - - res.json({ success: true, allDisplays }); -}); - -app.post("/register", async (req, res) => { - const { username } = req.body; - const password = await bcrypt.hash(req.body.password, 10); - try { - await User.create({ - username, - password: password, - admin: false, - }); - return res.json({ success: true, message: "Register successful" }); - } catch (error) { - if (error.code === 11000) { - return res.json({ success: false, message: "Username already exists" }); - } - return res.json({ success: false, message: error.message }); - } -}); +app.use( + createContentRouter({ + Art, + BirdPost, + FeatureHome, + Pic, + Post, + creationBottom, + verifyToken, + }), +); app.get("/adminDataGet", requireAdmin, async (req, res) => { const plants = await Post.find({ authorization: true }); @@ -347,95 +225,6 @@ app.post("/adminToggle", requireAdmin, async (req, res) => { res.json({ success: true }); }); -app.get("/userInfo", verifyToken, async (req, res) => { - const featureLists = await FeatureHome.find().lean(); - if (req.user) { - res.json({ - success: true, - username: req.user?.username, - admin: req.user?.admin, - featureLists, - }); - } else { - res.json({ success: false, featureLists }); - } -}); - -app.get("/userInfoGlossary", verifyToken, async (req, res) => { - const posts = await Post.find({ authorization: true }); - const letters = "abcdefghijklmnopqrstuvwxyz".split(""); - const glossary = {}; - const cnNames = {}; - - // Initialize an array for each letter - letters.forEach((letter) => { - glossary[letter] = []; - cnNames[letter] = []; - }); - - posts.forEach((post) => { - const firstLetter = post.latinName[0].toLowerCase(); - if (glossary[firstLetter]) { - glossary[firstLetter].push(post.latinName); - cnNames[firstLetter].push(post.chineseName); - } - }); - - const response = req.user - ? { - success: true, - username: req.user?.username, - admin: req.user?.admin, - glossary, - cnNames, - } - : { - success: false, - glossary, - cnNames, - }; - - res.json(response); -}); - -app.get("/userInfoGlossaryBird", verifyToken, async (req, res) => { - const posts = await BirdPost.find({ authorization: true }); - const letters = "abcdefghijklmnopqrstuvwxyz".split(""); - const glossary = {}; - const cnNames = {}; - - // Initialize an array for each letter - letters.forEach((letter) => { - glossary[letter] = []; - cnNames[letter] = []; - }); - - posts.forEach((post) => { - const firstLetter = post.latinName[0].toLowerCase(); - if (glossary[firstLetter]) { - glossary[firstLetter].push(post.latinName); - cnNames[firstLetter].push(post.chineseName); - } - }); - - const response = req.user - ? { - success: true, - username: req.user?.username, - admin: req.user?.admin, - glossary, - cnNames, - } - : { - success: false, - glossary, - cnNames, - }; - - res.json(response); - console.log("123") -}); - app.post("/edit", requireAuth, async function (req, res) { try { res.json({ success: true }); @@ -549,15 +338,9 @@ app.post("/uploadCreation", requireAuth, uploadWithCleanup, async (req, res) => const uploadedFiles = getUploadedFiles(files); inputFiles.push(...uploadedFiles.map((file) => file.path)); outputFiles.push( - ...uploadedFiles.map((file) => - path.join(__dirname, "public", "plantspic", file.filename), - ), + ...uploadedFiles.map((file) => getPlantMediaPath(file.filename)), ); - const outputFolderPath = `${path.join( - __dirname, - "public", - "plantspic", - )}${path.sep}`; + const outputFolderPath = getPlantMediaDirectory(); const plant = await Post.findOne({ latinName: body.plant }); if (!plant) { @@ -838,17 +621,11 @@ app.post("/uploadArt", requireAuth, artmiddleware, async function (req, res) { const outputFiles = []; const createdArts = []; const uploadedFiles = getUploadedFiles(req.files); - const outputFolderPath = `${path.join( - __dirname, - "public", - "plantspic", - )}${path.sep}`; + const outputFolderPath = getPlantMediaDirectory(); inputFiles.push(...uploadedFiles.map((file) => file.path)); outputFiles.push( - ...uploadedFiles.map((file) => - path.join(__dirname, "public", "plantspic", file.filename), - ), + ...uploadedFiles.map((file) => getPlantMediaPath(file.filename)), ); try { @@ -937,29 +714,14 @@ app.post( const outputFiles = []; const createdPics = []; const uploadedFiles = getUploadedFiles(req.files); - const outputFolderPath = `${path.join( - __dirname, - "public", - "plantspic", - )}${path.sep}`; - const smallerCompressedFolderPath = `${path.join( - __dirname, - "public", - "compressed", - "plantspic", - )}${path.sep}`; + const outputFolderPath = getPlantMediaDirectory(); + const smallerCompressedFolderPath = getCompressedPlantMediaDirectory(); inputFiles.push(...uploadedFiles.map((file) => file.path)); outputFiles.push( ...uploadedFiles.flatMap((file) => [ - path.join(__dirname, "public", "plantspic", file.filename), - path.join( - __dirname, - "public", - "compressed", - "plantspic", - file.filename, - ), + getPlantMediaPath(file.filename), + getCompressedPlantMediaPath(file.filename), ]), ); @@ -1073,29 +835,14 @@ app.post( const outputFiles = []; const createdPics = []; const uploadedFiles = getUploadedFiles(req.files); - const outputFolderPath = `${path.join( - __dirname, - "public", - "plantspic", - )}${path.sep}`; - const smallerCompressedFolderPath = `${path.join( - __dirname, - "public", - "compressed", - "plantspic", - )}${path.sep}`; + const outputFolderPath = getPlantMediaDirectory(); + const smallerCompressedFolderPath = getCompressedPlantMediaDirectory(); inputFiles.push(...uploadedFiles.map((file) => file.path)); outputFiles.push( ...uploadedFiles.flatMap((file) => [ - path.join(__dirname, "public", "plantspic", file.filename), - path.join( - __dirname, - "public", - "compressed", - "plantspic", - file.filename, - ), + getPlantMediaPath(file.filename), + getCompressedPlantMediaPath(file.filename), ]), ); @@ -1247,41 +994,6 @@ app.post("/adminMakeAdminUser", requireAdmin, async (req, res) => { } }); -app.post("/getPics", async (req, res) => { - try { - const pics = await Pic.find({ plant: req.body.plant }); - - var springPics = []; - var summerPics = []; - var autumnPics = []; - var winterPics = []; - - if (Array.isArray(pics) && pics.length > 0) { - pics.forEach((pic) => { - if (pic.season == "spring") { - springPics.push(pic); - } else if (pic.season == "summer") { - summerPics.push(pic); - } else if (pic.season == "autumn") { - autumnPics.push(pic); - } else if (pic.season == "winter") { - winterPics.push(pic); - } - }); - } - - res.json({ - success: true, - springPics: springPics, - summerPics: summerPics, - autumnPics: autumnPics, - winterPics: winterPics, - }); - } catch (error) { - console.log(error); - } -}); - app.post("/makePicFeatured", requireAdmin, async (req, res) => { const pic = await Pic.findById(req.body.id); pic.featured = !pic.featured; @@ -1730,46 +1442,6 @@ app.post("/unFeatureCreation", requireAdmin, uploadWithCleanup, async (req, res) } }); -app.get("/getDb2Pic", async (req, res) => { - const pics = await Pic.aggregate([ - { $sample: { size: 3 } }, - { $project: { path: 1, _id: 0 } }, - ]); - res.json({ success: true, pics }); -}); - -app.get("/getDb2PicBird", async (req, res) => { - const pics = await Pic.aggregate([ - { $match: {dbType: 'bird' }}, - { $sample: { size: 3 } }, - { $project: { path: 1, _id: 0} }, - ]); - res.json({ success: true, pics }); -}); - -app.get("/db2Alt", async (req, res) => { - const pic = await Pic.aggregate([ - { $sample: { size: 1 } }, - { $project: { path: 1, _id: 0 } }, - ]); - if (!pic) { - res.json({ success: false }); - } - res.json({ success: true, pic }); -}); - -app.get("/db2AltBird", async (req, res) => { - const pic = await Pic.aggregate([ - { $match: {dbType: 'bird' }}, - { $sample: { size: 1 } }, - { $project: { path: 1, _id: 0} }, - ]); - if (!pic) { - res.json({ success: false }); - } - res.json({ success: true, pic }); -}); - app.post("/editPageDelete", requireAdmin, async (req, res) => { try { const pic = await Pic.findOne({ _id: req.body.id }); @@ -1823,16 +1495,6 @@ app.delete("/editPageDeletePlant", requireAdmin, async (req, res) => { } }); -app.post("/getPicsAndArts", async (req, res) => { - try { - const pics = await Pic.find({ plant: req.body.plant }); - const arts = await Art.find({ plant: req.body.plant }); - res.json({ success: true, pics, arts }); - } catch (error) { - res.status(500).json({ success: false, message: error.message }); - } -}); - app.use(function (err, req, res, next) { if (res.headersSent) { return next(err); diff --git a/routes/auth.js b/routes/auth.js new file mode 100644 index 0000000..c6e11fa --- /dev/null +++ b/routes/auth.js @@ -0,0 +1,147 @@ +const express = require("express"); + +function createAuthRouter({ + User, + bcrypt, + getAuthorizationToken, + jwt, + runtime, + sessionStoreUnavailable, + verifyToken, +}) { + const router = express.Router(); + + router.post("/login", verifyToken, async (req, res) => { + var passwordMatch = false; + if (req.sessionStoreUnavailable) { + return sessionStoreUnavailable(res); + } + + if (req.user) { + return res.json({ + success: true, + message: "Already logged in", + user: req.user, + token: req.token, + }); + } + + const { username, password } = req.body; + const user = await User.findOne({ username }).select("+password"); + + if (user) { + passwordMatch = await bcrypt.compare(password, user.password); + } else { + return res.json({ success: false, message: "User not found" }); + } + + if (passwordMatch) { + if (!runtime.isSessionStoreReady()) { + return sessionStoreUnavailable(res); + } + + var token = jwt.sign( + { username: user.username, admin: user.admin }, + process.env.secret, + { expiresIn: "180d" }, + ); + + try { + const redisToken = await runtime.getSessionToken(username); + let hasUsableSession = false; + + if (redisToken) { + try { + const sessionPayload = jwt.verify(redisToken, process.env.secret); + hasUsableSession = sessionPayload?.username === username; + } catch (_) { + hasUsableSession = false; + } + } + + if (hasUsableSession) { + token = redisToken; + } else { + await runtime.deleteSession(username); + await runtime.setSessionToken(username, token); + } + + return res.json({ + success: true, + message: "Login successful", + user: (({ username, admin }) => ({ username, admin }))(user), + token, + }); + } catch (_) { + console.warn("Unable to create a login session"); + return sessionStoreUnavailable(res); + } + } else { + return res.json({ + success: false, + message: "Invalid username or password", + }); + } + }); + + router.get("/refresh", verifyToken, async (req, res) => { + if (req.sessionStoreUnavailable) { + return sessionStoreUnavailable(res); + } + + if (!req.user) { + return res.json({ success: false, message: "Token not valid" }); + } + + return res.json({ + success: true, + message: "Token refreshed", + user: req.user, + }); + }); + + router.post("/logout", verifyToken, async (req, res) => { + if (!req.user) { + if ( + req.sessionStoreUnavailable || + (getAuthorizationToken(req.headers["authorization"]) && + !runtime.isSessionStoreReady()) + ) { + return sessionStoreUnavailable(res); + } + + return res.json({ success: true, message: "Logout successful" }); + } + + try { + await runtime.deleteSession(req.user.username); + } catch (_) { + console.warn("Unable to remove login session"); + return sessionStoreUnavailable(res); + } + + return res.json({ success: true, message: "Logout successful" }); + }); + + router.post("/register", async (req, res) => { + const { username } = req.body; + const password = await bcrypt.hash(req.body.password, 10); + try { + await User.create({ + username, + password: password, + admin: false, + }); + return res.json({ success: true, message: "Register successful" }); + } catch (error) { + if (error.code === 11000) { + return res.json({ success: false, message: "Username already exists" }); + } + return res.json({ success: false, message: error.message }); + } + }); + + return router; +} + +module.exports = { createAuthRouter }; diff --git a/routes/content.js b/routes/content.js new file mode 100644 index 0000000..c59d9fa --- /dev/null +++ b/routes/content.js @@ -0,0 +1,196 @@ +const express = require("express"); + +function createContentRouter({ + Art, + BirdPost, + FeatureHome, + Pic, + Post, + creationBottom, + verifyToken, +}) { + const router = express.Router(); + + router.get("/creationDocumentary", async (req, res) => { + const allDisplays = await creationBottom.find({ auth: true }); + + res.json({ success: true, allDisplays }); + }); + + router.get("/userInfo", verifyToken, async (req, res) => { + const featureLists = await FeatureHome.find().lean(); + if (req.user) { + res.json({ + success: true, + username: req.user?.username, + admin: req.user?.admin, + featureLists, + }); + } else { + res.json({ success: false, featureLists }); + } + }); + + router.get("/userInfoGlossary", verifyToken, async (req, res) => { + const posts = await Post.find({ authorization: true }); + const letters = "abcdefghijklmnopqrstuvwxyz".split(""); + const glossary = {}; + const cnNames = {}; + + // Initialize an array for each letter + letters.forEach((letter) => { + glossary[letter] = []; + cnNames[letter] = []; + }); + + posts.forEach((post) => { + const firstLetter = post.latinName[0].toLowerCase(); + if (glossary[firstLetter]) { + glossary[firstLetter].push(post.latinName); + cnNames[firstLetter].push(post.chineseName); + } + }); + + const response = req.user + ? { + success: true, + username: req.user?.username, + admin: req.user?.admin, + glossary, + cnNames, + } + : { + success: false, + glossary, + cnNames, + }; + + res.json(response); + }); + + router.get("/userInfoGlossaryBird", verifyToken, async (req, res) => { + const posts = await BirdPost.find({ authorization: true }); + const letters = "abcdefghijklmnopqrstuvwxyz".split(""); + const glossary = {}; + const cnNames = {}; + + // Initialize an array for each letter + letters.forEach((letter) => { + glossary[letter] = []; + cnNames[letter] = []; + }); + + posts.forEach((post) => { + const firstLetter = post.latinName[0].toLowerCase(); + if (glossary[firstLetter]) { + glossary[firstLetter].push(post.latinName); + cnNames[firstLetter].push(post.chineseName); + } + }); + + const response = req.user + ? { + success: true, + username: req.user?.username, + admin: req.user?.admin, + glossary, + cnNames, + } + : { + success: false, + glossary, + cnNames, + }; + + res.json(response); + }); + + router.post("/getPics", async (req, res) => { + try { + const pics = await Pic.find({ plant: req.body.plant }); + + var springPics = []; + var summerPics = []; + var autumnPics = []; + var winterPics = []; + + if (Array.isArray(pics) && pics.length > 0) { + pics.forEach((pic) => { + if (pic.season == "spring") { + springPics.push(pic); + } else if (pic.season == "summer") { + summerPics.push(pic); + } else if (pic.season == "autumn") { + autumnPics.push(pic); + } else if (pic.season == "winter") { + winterPics.push(pic); + } + }); + } + + res.json({ + success: true, + springPics: springPics, + summerPics: summerPics, + autumnPics: autumnPics, + winterPics: winterPics, + }); + } catch (error) { + console.log(error); + } + }); + + router.get("/getDb2Pic", async (req, res) => { + const pics = await Pic.aggregate([ + { $sample: { size: 3 } }, + { $project: { path: 1, _id: 0 } }, + ]); + res.json({ success: true, pics }); + }); + + router.get("/getDb2PicBird", async (req, res) => { + const pics = await Pic.aggregate([ + { $match: {dbType: 'bird' }}, + { $sample: { size: 3 } }, + { $project: { path: 1, _id: 0} }, + ]); + res.json({ success: true, pics }); + }); + + router.get("/db2Alt", async (req, res) => { + const pic = await Pic.aggregate([ + { $sample: { size: 1 } }, + { $project: { path: 1, _id: 0 } }, + ]); + if (!pic) { + res.json({ success: false }); + } + res.json({ success: true, pic }); + }); + + router.get("/db2AltBird", async (req, res) => { + const pic = await Pic.aggregate([ + { $match: {dbType: 'bird' }}, + { $sample: { size: 1 } }, + { $project: { path: 1, _id: 0} }, + ]); + if (!pic) { + res.json({ success: false }); + } + res.json({ success: true, pic }); + }); + + router.post("/getPicsAndArts", async (req, res) => { + try { + const pics = await Pic.find({ plant: req.body.plant }); + const arts = await Art.find({ plant: req.body.plant }); + res.json({ success: true, pics, arts }); + } catch (error) { + res.status(500).json({ success: false, message: error.message }); + } + }); + + return router; +} + +module.exports = { createContentRouter }; diff --git a/services/mediaFiles.js b/services/mediaFiles.js new file mode 100644 index 0000000..67da001 --- /dev/null +++ b/services/mediaFiles.js @@ -0,0 +1,55 @@ +const fs = require("fs").promises; +const path = require("path"); + +const PUBLIC_DIRECTORY = path.join(__dirname, "..", "public"); +const PLANT_MEDIA_DIRECTORY = path.join(PUBLIC_DIRECTORY, "plantspic"); +const COMPRESSED_PLANT_MEDIA_DIRECTORY = path.join( + PUBLIC_DIRECTORY, + "compressed", + "plantspic", +); + +function getUploadedFiles(files) { + if (Array.isArray(files)) { + return files; + } + + return Object.values(files || {}).flat(); +} + +function getPlantMediaDirectory() { + return `${PLANT_MEDIA_DIRECTORY}${path.sep}`; +} + +function getCompressedPlantMediaDirectory() { + return `${COMPRESSED_PLANT_MEDIA_DIRECTORY}${path.sep}`; +} + +function getPlantMediaPath(filename) { + return path.join(PLANT_MEDIA_DIRECTORY, filename); +} + +function getCompressedPlantMediaPath(filename) { + return path.join(COMPRESSED_PLANT_MEDIA_DIRECTORY, filename); +} + +async function cleanupFiles(filePaths) { + await Promise.all( + filePaths.map((filePath) => + fs.unlink(filePath).catch((error) => { + if (error.code !== "ENOENT") { + console.warn("Unable to clean up uploaded file"); + } + }), + ), + ); +} + +module.exports = { + cleanupFiles, + getCompressedPlantMediaDirectory, + getCompressedPlantMediaPath, + getPlantMediaDirectory, + getPlantMediaPath, + getUploadedFiles, +}; diff --git a/test/app.contract.test.js b/test/app.contract.test.js index c3538a9..8c56e9a 100644 --- a/test/app.contract.test.js +++ b/test/app.contract.test.js @@ -7,6 +7,8 @@ const Post = require("../models/post"); const BirdPost = require("../models/birdPost"); const Pic = require("../models/pic"); const Art = require("../models/art"); +const creationBottom = require("../models/creationBottom"); +const FeatureHome = require("../models/featureHome"); const { app, runtime } = require("../app"); let server; @@ -15,7 +17,10 @@ const originals = { postFind: Post.find, birdPostFind: BirdPost.find, picFind: Pic.find, + picAggregate: Pic.aggregate, artFind: Art.find, + creationBottomFind: creationBottom.find, + featureHomeFind: FeatureHome.find, }; function request(pathname, { method = "GET", body, headers = {} } = {}) { @@ -97,7 +102,10 @@ after(async () => { Post.find = originals.postFind; BirdPost.find = originals.birdPostFind; Pic.find = originals.picFind; + Pic.aggregate = originals.picAggregate; Art.find = originals.artFind; + creationBottom.find = originals.creationBottomFind; + FeatureHome.find = originals.featureHomeFind; await new Promise((resolve, reject) => { server.close((error) => { @@ -210,3 +218,105 @@ test("public read routes retain search, detail, count, refresh, and CORS contrac { model: "BirdPost", query: { authorization: true } }, ]); }); + +test("public content routes retain documentary, glossary, media, and feature contracts", async () => { + Post.find = async (query) => { + calls.push({ model: "Post", query }); + return [ + { + latinName: "Acer rubrum", + chineseName: "红花槭", + authorization: true, + }, + ]; + }; + BirdPost.find = async (query) => { + calls.push({ model: "BirdPost", query }); + return [ + { + latinName: "Corvus corax", + chineseName: "渡鸦", + authorization: true, + }, + ]; + }; + Pic.find = async (query) => { + calls.push({ model: "Pic", query }); + return [ + { path: "/plantspic/spring.jpg", season: "spring" }, + { path: "/plantspic/winter.jpg", season: "winter" }, + ]; + }; + Pic.aggregate = async (pipeline) => { + calls.push({ model: "Pic", pipeline }); + return [{ path: "/plantspic/random.jpg" }]; + }; + Art.find = async (query) => { + calls.push({ model: "Art", query }); + return [{ path: "/plantspic/art.jpg" }]; + }; + creationBottom.find = async (query) => { + calls.push({ model: "creationBottom", query }); + return [{ plant: "Acer rubrum", auth: true }]; + }; + FeatureHome.find = () => ({ + lean: async () => [{ plant: "Acer rubrum" }], + }); + + const documentary = await request("/creationDocumentary"); + const userInfo = await request("/userInfo"); + const plantGlossary = await request("/userInfoGlossary"); + const birdGlossary = await request("/userInfoGlossaryBird"); + const seasonalPics = await request("/getPics", { + method: "POST", + body: { plant: "Acer rubrum" }, + }); + const randomPics = await request("/getDb2Pic"); + const randomBirdPics = await request("/getDb2PicBird"); + const randomAlt = await request("/db2Alt"); + const randomBirdAlt = await request("/db2AltBird"); + const media = await request("/getPicsAndArts", { + method: "POST", + body: { plant: "Acer rubrum" }, + }); + + assert.deepEqual(documentary.body, { + success: true, + allDisplays: [{ plant: "Acer rubrum", auth: true }], + }); + assert.deepEqual(userInfo.body, { + success: false, + featureLists: [{ plant: "Acer rubrum" }], + }); + assert.equal(plantGlossary.body.success, false); + assert.deepEqual(plantGlossary.body.glossary.a, ["Acer rubrum"]); + assert.deepEqual(plantGlossary.body.cnNames.a, ["红花槭"]); + assert.equal(birdGlossary.body.success, false); + assert.deepEqual(birdGlossary.body.glossary.c, ["Corvus corax"]); + assert.deepEqual(birdGlossary.body.cnNames.c, ["渡鸦"]); + assert.deepEqual(seasonalPics.body, { + success: true, + springPics: [{ path: "/plantspic/spring.jpg", season: "spring" }], + summerPics: [], + autumnPics: [], + winterPics: [{ path: "/plantspic/winter.jpg", season: "winter" }], + }); + assert.deepEqual(randomPics.body, { + success: true, + pics: [{ path: "/plantspic/random.jpg" }], + }); + assert.deepEqual(randomBirdPics.body, randomPics.body); + assert.deepEqual(randomAlt.body, { + success: true, + pic: [{ path: "/plantspic/random.jpg" }], + }); + assert.deepEqual(randomBirdAlt.body, randomAlt.body); + assert.deepEqual(media.body, { + success: true, + pics: [ + { path: "/plantspic/spring.jpg", season: "spring" }, + { path: "/plantspic/winter.jpg", season: "winter" }, + ], + arts: [{ path: "/plantspic/art.jpg" }], + }); +}); diff --git a/test/auth-boundary.test.js b/test/auth-boundary.test.js index 454a2b5..a0b21f2 100644 --- a/test/auth-boundary.test.js +++ b/test/auth-boundary.test.js @@ -19,6 +19,7 @@ let server; let sessionStoreAvailable = true; const sessions = new Map(); const originals = { + userCreate: User.create, userFind: User.find, userFindOne: User.findOne, postFind: Post.find, @@ -111,6 +112,7 @@ before(async () => { beforeEach(() => { sessionStoreAvailable = true; sessions.clear(); + User.create = originals.userCreate; User.find = originals.userFind; User.findOne = originals.userFindOne; Post.find = originals.postFind; @@ -118,6 +120,7 @@ beforeEach(() => { }); after(async () => { + User.create = originals.userCreate; User.find = originals.userFind; User.findOne = originals.userFindOne; Post.find = originals.postFind; @@ -298,6 +301,47 @@ test("login and logout report a session-store outage", async () => { }); }); +test("registration retains its public response and stored user shape", async () => { + const createdUsers = []; + User.create = async (user) => { + createdUsers.push(user); + return user; + }; + + const registered = await request("/register", { + method: "POST", + body: { username: "new-member", password: "registration-password" }, + }); + + assert.equal(registered.status, 200); + assert.deepEqual(registered.body, { + success: true, + message: "Register successful", + }); + assert.equal(createdUsers.length, 1); + assert.equal(createdUsers[0].username, "new-member"); + assert.equal(createdUsers[0].admin, false); + assert.equal("originalPassword" in createdUsers[0], false); + assert.equal( + await bcrypt.compare("registration-password", createdUsers[0].password), + true, + ); + + User.create = async () => { + const error = new Error("duplicate key"); + error.code = 11000; + throw error; + }; + const duplicate = await request("/register", { + method: "POST", + body: { username: "new-member", password: "registration-password" }, + }); + assert.deepEqual(duplicate.body, { + success: false, + message: "Username already exists", + }); +}); + test("spoofed multipart images are rejected and removed without path traversal", async () => { const token = jwt.sign( { username: "member", admin: false }, diff --git a/test/media-files.test.js b/test/media-files.test.js new file mode 100644 index 0000000..2cde5c9 --- /dev/null +++ b/test/media-files.test.js @@ -0,0 +1,78 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); +const { test } = require("node:test"); + +const { + cleanupFiles, + getCompressedPlantMediaDirectory, + getCompressedPlantMediaPath, + getPlantMediaDirectory, + getPlantMediaPath, + getUploadedFiles, +} = require("../services/mediaFiles"); + +test("media path helpers retain the existing public directory layout", () => { + const applicationRoot = path.resolve(__dirname, ".."); + const plantMediaDirectory = path.join( + applicationRoot, + "public", + "plantspic", + ); + const compressedPlantMediaDirectory = path.join( + applicationRoot, + "public", + "compressed", + "plantspic", + ); + + assert.equal(getPlantMediaDirectory(), `${plantMediaDirectory}${path.sep}`); + assert.equal( + getCompressedPlantMediaDirectory(), + `${compressedPlantMediaDirectory}${path.sep}`, + ); + assert.equal( + getPlantMediaPath("example.png"), + path.join(plantMediaDirectory, "example.png"), + ); + assert.equal( + getCompressedPlantMediaPath("example.png"), + path.join(compressedPlantMediaDirectory, "example.png"), + ); +}); + +test("upload collection and cleanup retain array, field-map, and missing-file behavior", async () => { + const firstFile = { path: "/tmp/first" }; + const secondFile = { path: "/tmp/second" }; + + assert.deepEqual(getUploadedFiles([firstFile, secondFile]), [ + firstFile, + secondFile, + ]); + assert.deepEqual( + getUploadedFiles({ pic: [firstFile], art: [secondFile] }), + [firstFile, secondFile], + ); + assert.deepEqual(getUploadedFiles(undefined), []); + + const temporaryDirectory = await fs.mkdtemp( + path.join(os.tmpdir(), "biphflora-media-files-"), + ); + const existingFile = path.join(temporaryDirectory, "existing.tmp"); + const missingFile = path.join(temporaryDirectory, "missing.tmp"); + await fs.writeFile(existingFile, "temporary upload"); + + try { + await cleanupFiles([existingFile, missingFile]); + assert.equal( + await fs + .stat(existingFile) + .then(() => true) + .catch(() => false), + false, + ); + } finally { + await fs.rm(temporaryDirectory, { recursive: true, force: true }); + } +}); From 07f893b4e90eb6d8baf8e5ff41f700cf6b11022a Mon Sep 17 00:00:00 2001 From: LQ458 Date: Wed, 22 Jul 2026 16:38:58 +0800 Subject: [PATCH 06/31] refactor: centralize client data access --- client/src/UserContext.js | 16 ++- client/src/api/http.js | 8 +- client/src/components/Admin.js | 9 +- client/src/components/AdminAuth.js | 19 ++-- client/src/components/AdminView.js | 9 +- client/src/components/BirdEditPageTextForm.js | 5 +- client/src/components/BirdGalleryDatabase.js | 19 ++-- client/src/components/BirdHomeDatabase.js | 17 +-- client/src/components/BirdInfoDatabase.js | 25 ++-- client/src/components/CreationPaintings.js | 9 +- client/src/components/EditPageTextForm.js | 5 +- client/src/components/GalleryDatabase.js | 19 ++-- client/src/components/HomeDatabase.js | 17 +-- client/src/components/InfoDatabase.js | 25 ++-- client/src/components/SubBox.js | 9 +- client/src/components/UploadBird.js | 13 ++- client/src/components/UploadCreation.js | 27 ++--- client/src/components/UploadHome.js | 27 ++--- client/src/components/UploadPlants.js | 13 ++- client/src/pages/AboutUs.js | 11 +- client/src/pages/BirdEditPage.js | 13 ++- client/src/pages/BirdGlossary.js | 5 +- client/src/pages/EditPage.js | 13 ++- client/src/pages/Glossary.js | 5 +- client/src/pages/Home.js | 11 +- client/src/pages/Login.js | 5 +- client/src/pages/Register.js | 7 +- client/src/pages/Upload.js | 5 +- client/src/tools/url.js | 107 ++++++++++++------ client/src/tools/url.test.js | 19 ++++ 30 files changed, 285 insertions(+), 207 deletions(-) create mode 100644 client/src/tools/url.test.js diff --git a/client/src/UserContext.js b/client/src/UserContext.js index a11994e..686e6e6 100644 --- a/client/src/UserContext.js +++ b/client/src/UserContext.js @@ -2,6 +2,7 @@ import { createContext, useReducer, useEffect } from "react"; import { Map, fromJS } from "immutable"; import axios from "./api/http"; +import urls from "./tools/url"; const userReducer = (state, action) => { switch (action.type) { @@ -24,14 +25,11 @@ export const UserProvider = ({ children }) => { const refresh = async () => { try { const token = localStorage.getItem("askanything"); - const response = await axios.get( - `${process.env.REACT_APP_Source_URL}/refresh`, - { - headers: { - authorization: token, - }, + const response = await axios.get(urls.refresh, { + headers: { + authorization: token, }, - ); + }); if (response.data.user) { dispatch({ type: "LOGIN", payload: response.data.user }); } else { @@ -50,7 +48,7 @@ export const UserProvider = ({ children }) => { try { const token = localStorage.getItem("askanything"); const response = await axios.post( - `${process.env.REACT_APP_Source_URL}/login`, + urls.login, { username, password, @@ -84,7 +82,7 @@ export const UserProvider = ({ children }) => { const token = localStorage.getItem("askanything"); localStorage.removeItem("askanything"); const response = await axios.post( - `${process.env.REACT_APP_Source_URL}/logout`, + urls.logout, {}, { headers: { diff --git a/client/src/api/http.js b/client/src/api/http.js index 55ac5d6..66f53ae 100644 --- a/client/src/api/http.js +++ b/client/src/api/http.js @@ -1,14 +1,12 @@ import axios from "axios"; +import { apiOrigin } from "../tools/url"; axios.defaults.withCredentials = true; axios.interceptors.request.use((config) => { const token = localStorage.getItem("askanything"); - const apiBaseUrl = process.env.REACT_APP_Source_URL; - const normalizedApiBaseUrl = apiBaseUrl?.replace(/\/$/, ""); const isApiRequest = - normalizedApiBaseUrl && - (config.url === normalizedApiBaseUrl || - config.url?.startsWith(`${normalizedApiBaseUrl}/`)); + apiOrigin && + (config.url === apiOrigin || config.url?.startsWith(`${apiOrigin}/`)); if (!token || !isApiRequest) { return config; diff --git a/client/src/components/Admin.js b/client/src/components/Admin.js index bdda9d7..e8e98e6 100644 --- a/client/src/components/Admin.js +++ b/client/src/components/Admin.js @@ -1,5 +1,6 @@ import React, { useState, useEffect } from "react"; -import axios from "axios"; +import axios from "../api/http"; +import urls from "../tools/url"; import { useNavigate } from "react-router-dom"; import Navbar from "./Navbar.js"; import "../styles/admin.css"; @@ -15,7 +16,7 @@ const Admin = ({ handleAdminPreview }) => { const fetchAdmin = async () => { try { const response = await axios.get( - `${process.env.REACT_APP_Source_URL}/userInfo`, + urls.userInfo, ); setAdmin(response.data.admin); if (!response.data.admin) { @@ -33,7 +34,7 @@ const Admin = ({ handleAdminPreview }) => { try { await fetchAdmin(); const response = await axios.get( - `${process.env.REACT_APP_Source_URL}/adminDataGet`, + urls.adminDataGet, ); setUsers(response.data.users); setPlants(response.data.plants); @@ -60,7 +61,7 @@ const Admin = ({ handleAdminPreview }) => { setUserLoadingState("loading..."); try { const response = await axios.post( - `${process.env.REACT_APP_Source_URL}/adminToggle`, + urls.adminToggle, { username: name }, ); setUserLoadingState("done"); diff --git a/client/src/components/AdminAuth.js b/client/src/components/AdminAuth.js index 0f7b075..a26b7d3 100644 --- a/client/src/components/AdminAuth.js +++ b/client/src/components/AdminAuth.js @@ -1,4 +1,5 @@ -import axios from "axios"; +import axios from "../api/http"; +import urls, { mediaUrl } from "../tools/url"; import { useNavigate } from "react-router-dom"; import React, { useState, useEffect, useRef } from "react"; import { Button } from "primereact/button"; @@ -24,12 +25,12 @@ const AdminAuth = ({ admin }) => { // console.log(id); // console.log(decision) await axios.put( - `${process.env.REACT_APP_Source_URL}/handleEditDecision`, + urls.handleEditDecision, { id: id, decision: decision }, ); }else{ await axios.put( - `${process.env.REACT_APP_Source_URL}/handleBirdEditDecision`, + urls.handleBirdEditDecision, { id: id, decision: decision }, ); } @@ -54,7 +55,7 @@ const AdminAuth = ({ admin }) => { const fetchData = async () => { try { const response = await axios.get( - `${process.env.REACT_APP_Source_URL}/adminAuth`, + urls.adminAuth, ); if (!admin) { toast.current.show({ @@ -82,7 +83,7 @@ const AdminAuth = ({ admin }) => { const handleNewCreationDecision = async (decision, id, dbType) => { try { - await axios.post(`${process.env.REACT_APP_Source_URL}/newCreationAuth`, { + await axios.post(urls.newCreationAuth, { id: id, decision: decision, }); @@ -106,7 +107,7 @@ const AdminAuth = ({ admin }) => { const handleNewPostDecision = async (decision, id, dbType) => { if(dbType === "bird"){ try { - await axios.post(`${process.env.REACT_APP_Source_URL}/newBirdPostAuth`, { + await axios.post(urls.newBirdPostAuth, { id: id, decision: decision, }); @@ -127,7 +128,7 @@ const AdminAuth = ({ admin }) => { } }else{ try { - await axios.post(`${process.env.REACT_APP_Source_URL}/newPostAuth`, { + await axios.post(urls.newPostAuth, { id: id, decision: decision, }); @@ -417,7 +418,7 @@ const AdminAuth = ({ admin }) => {
{item.pic} @@ -425,7 +426,7 @@ const AdminAuth = ({ admin }) => {
{item.art} diff --git a/client/src/components/AdminView.js b/client/src/components/AdminView.js index fe58500..6cab8c2 100644 --- a/client/src/components/AdminView.js +++ b/client/src/components/AdminView.js @@ -1,5 +1,6 @@ import React, { useState, useEffect } from "react"; -import axios from "axios"; +import axios from "../api/http"; +import urls from "../tools/url"; import { useNavigate } from "react-router-dom"; const AdminView = (previewKey) => { @@ -20,7 +21,7 @@ const AdminView = (previewKey) => { const fetchAdmin = async () => { try { const response = await axios.get( - `${process.env.REACT_APP_Source_URL}/userInfo`, + urls.userInfo, ); setAdmin(response.data.admin); if (!response.data.admin) { @@ -47,7 +48,7 @@ const AdminView = (previewKey) => { setFeatureStatus("Loading...."); try { const response = await axios.post( - `${process.env.REACT_APP_Source_URL}/makeFeatured`, + urls.makeFeatured, { pic: cBT.Pic, art: cBT.Art, plant: search }, ); setFeatureStatus("Done!"); @@ -64,7 +65,7 @@ const AdminView = (previewKey) => { try { await fetchAdmin(); const response = await axios.post( - `${process.env.REACT_APP_Source_URL}/adminView`, + urls.adminView, { search: search }, ); setPlant(response.data.resultPlant); diff --git a/client/src/components/BirdEditPageTextForm.js b/client/src/components/BirdEditPageTextForm.js index 62c99e7..8e3f17c 100644 --- a/client/src/components/BirdEditPageTextForm.js +++ b/client/src/components/BirdEditPageTextForm.js @@ -1,4 +1,5 @@ -import axios from "axios"; +import axios from "../api/http"; +import urls from "../tools/url"; import React, { useState, useEffect, useRef } from "react"; import { InputText } from "primereact/inputtext"; import { InputTextarea } from "primereact/inputtextarea"; @@ -65,7 +66,7 @@ const BirdEditPageTextForm = (prop) => { setLoadingMessage("loading..."); try { - await axios.post(`${process.env.REACT_APP_Source_URL}/birdUpdateText`, { + await axios.post(urls.birdUpdateText, { latinName, chineseName, commonName, diff --git a/client/src/components/BirdGalleryDatabase.js b/client/src/components/BirdGalleryDatabase.js index 07a4545..7243cd0 100755 --- a/client/src/components/BirdGalleryDatabase.js +++ b/client/src/components/BirdGalleryDatabase.js @@ -1,4 +1,5 @@ -import axios from "axios"; +import axios from "../api/http"; +import urls, { mediaUrl } from "../tools/url"; import React, { useState, useEffect, useMemo } from "react"; import styles from "../styles/galleryDatabase.module.css"; import SearchPlant from "./SearchPlant.js"; @@ -114,7 +115,7 @@ const GalleryDatabase = (props) => { const getPics = async () => { try { const response = await axios.post( - `${process.env.REACT_APP_Source_URL}/getPics`, + urls.getPics, { plant: plant }, ); let newArray = []; @@ -151,7 +152,7 @@ const GalleryDatabase = (props) => { const fetchData = async () => { try { const response = await axios.get( - `${process.env.REACT_APP_Source_URL}/searchNames`, + urls.searchNames, ); const fetchedNamesArray = response.data.returnNames; setNamesArray(fetchedNamesArray); @@ -188,7 +189,7 @@ const GalleryDatabase = (props) => { path: zoomPicLink, }; await axios.post( - `${process.env.REACT_APP_Source_URL}/uploadFeatureSingle`, + urls.uploadFeatureSingle, newFeature, { headers: { @@ -205,7 +206,7 @@ const GalleryDatabase = (props) => { const fetchUserInfo = async () => { try { const response = await axios.get( - `${process.env.REACT_APP_Source_URL}/userInfo`, + urls.userInfo, { headers: { Authorization: `${localStorage.getItem("askanything")}`, @@ -352,7 +353,7 @@ const GalleryDatabase = (props) => { .map((p, index) => (
@@ -406,7 +407,7 @@ const GalleryDatabase = (props) => { .map((p, index) => (
@@ -458,7 +459,7 @@ const GalleryDatabase = (props) => { .map((pic, picIndex) => (
@@ -511,7 +512,7 @@ const GalleryDatabase = (props) => {
{zoomPicLink} pic
art diff --git a/client/src/components/EditPageTextForm.js b/client/src/components/EditPageTextForm.js index cb34b9d..be93cfc 100644 --- a/client/src/components/EditPageTextForm.js +++ b/client/src/components/EditPageTextForm.js @@ -1,4 +1,5 @@ -import axios from "axios"; +import axios from "../api/http"; +import urls from "../tools/url"; import React, { useState, useEffect, useRef } from "react"; import { InputText } from "primereact/inputtext"; import { InputTextarea } from "primereact/inputtextarea"; @@ -54,7 +55,7 @@ const EditPageTextForm = (prop) => { try { console.log("Original latin22:") console.log(originalLatin) - await axios.post(`${process.env.REACT_APP_Source_URL}/updateText`, { + await axios.post(urls.updateText, { latinName, chineseName, commonName, diff --git a/client/src/components/GalleryDatabase.js b/client/src/components/GalleryDatabase.js index 55c1da7..8eb46c8 100755 --- a/client/src/components/GalleryDatabase.js +++ b/client/src/components/GalleryDatabase.js @@ -1,4 +1,5 @@ -import axios from "axios"; +import axios from "../api/http"; +import urls, { mediaUrl } from "../tools/url"; import React, { useState, useEffect, useMemo } from "react"; import styles from "../styles/galleryDatabase.module.css"; import SearchPlant from "./SearchPlant.js"; @@ -117,7 +118,7 @@ const GalleryDatabase = (props) => { const getPics = async () => { try { const response = await axios.post( - `${process.env.REACT_APP_Source_URL}/getPics`, + urls.getPics, { plant: plant }, ); let newArray = []; @@ -162,7 +163,7 @@ const GalleryDatabase = (props) => { const fetchData = async () => { try { const response = await axios.get( - `${process.env.REACT_APP_Source_URL}/searchNames`, + urls.searchNames, ); const fetchedNamesArray = response.data.returnNames; setNamesArray(fetchedNamesArray); @@ -200,7 +201,7 @@ const GalleryDatabase = (props) => { path: zoomPicLink, }; await axios.post( - `${process.env.REACT_APP_Source_URL}/uploadFeatureSingle`, + urls.uploadFeatureSingle, newFeature, { headers: { @@ -217,7 +218,7 @@ const GalleryDatabase = (props) => { const fetchUserInfo = async () => { try { const response = await axios.get( - `${process.env.REACT_APP_Source_URL}/userInfo`, + urls.userInfo, { headers: { Authorization: `${localStorage.getItem("askanything")}`, @@ -368,7 +369,7 @@ const GalleryDatabase = (props) => { .map((p, index) => (
@@ -422,7 +423,7 @@ const GalleryDatabase = (props) => { .map((p, index) => (
@@ -474,7 +475,7 @@ const GalleryDatabase = (props) => { .map((pic, picIndex) => (
@@ -527,7 +528,7 @@ const GalleryDatabase = (props) => {
{zoomPicLink}
*/}
- {seasons.map(s => ( + {seasons.map((s) => ( <> {s != "winter" &&
} - ))}
@@ -266,7 +253,7 @@ const GalleryDatabase = (props) => { setCurIndex(0); }} > - {month2NumMap[m.slice(0,3)]+m.slice(4,8)} + {month2NumMap[m.slice(0, 3)] + m.slice(4, 8)} ))} @@ -305,10 +292,6 @@ const GalleryDatabase = (props) => { }, [])}
*/} - - - - {/* {pics.map( (pic, index) => displays[index] && @@ -360,22 +343,26 @@ const GalleryDatabase = (props) => { ), )} */} - {selectedSeason && selectedMonth && (() => { - const picsToShow = seasonMonthMap[selectedSeason][selectedMonth] || []; - const pageSize = 12; - const start = curIndex * pageSize; - const end = start + pageSize; - return ( - // - <> + {selectedSeason && + selectedMonth && + (() => { + const picsToShow = + seasonMonthMap[selectedSeason][selectedMonth] || []; + const pageSize = 12; + const start = curIndex * pageSize; + const end = start + pageSize; + return ( + // + <>
{picsToShow .slice(curIndex * 12, (curIndex + 1) * 12) .map((p, index) => (
{
)} - ); - })()} + ); + })()} {/* {curMode === "month" && monthPics.map( diff --git a/client/src/components/BirdHomeDatabase.js b/client/src/components/BirdHomeDatabase.js index b767687..3da8da0 100755 --- a/client/src/components/BirdHomeDatabase.js +++ b/client/src/components/BirdHomeDatabase.js @@ -1,5 +1,5 @@ import axios from "../api/http"; -import urls, { mediaUrl } from "../tools/url"; +import urls, { responsiveMediaProps } from "../tools/url"; import { Link } from "react-router-dom"; import React, { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; @@ -23,9 +23,7 @@ const DatabaseTwo = ({ handleGet, setLoading }) => { useEffect(() => { const getDb2Pic = async () => { try { - const response = await axios.get( - urls.getDb2PicBird, - ); + const response = await axios.get(urls.getDb2PicBird); setPics(response.data.pics); } catch (error) { console.log(error); @@ -87,7 +85,6 @@ const DatabaseTwo = ({ handleGet, setLoading }) => { Switch to Plant Database 切换至植物检索数据库 -
{ return (
} - loading="eager" + loading={index === 0 ? "eager" : "lazy"} + fetchPriority={index === 0 ? "high" : "auto"} alt="plant" className="picImg" onClick={() => diff --git a/client/src/components/BirdInfoDatabase.js b/client/src/components/BirdInfoDatabase.js index f24f091..b2be638 100755 --- a/client/src/components/BirdInfoDatabase.js +++ b/client/src/components/BirdInfoDatabase.js @@ -1,5 +1,5 @@ import axios from "../api/http"; -import urls, { mediaUrl } from "../tools/url"; +import urls, { mediaUrl, responsiveMediaProps } from "../tools/url"; import { useNavigate } from "react-router-dom"; import { ReactComponent as PreviousIcon } from "../src/buttons/caret-back-outline.svg"; import { ReactComponent as NextIcon } from "../src/buttons/caret-forward-outline.svg"; @@ -77,10 +77,10 @@ const InfoDatabase = (search) => { const [stageChar, setStageChar] = useState([]); // const [displayedChar, setDisplayedChar] = useState([]); const indexes = { - Juvenile: 0, - Subadult: 1, - MaleAdult: 2, - FemaleAdult: 3, + Juvenile: 0, + Subadult: 1, + MaleAdult: 2, + FemaleAdult: 3, }; const featureSingleArtHandle = async (input) => { @@ -90,15 +90,11 @@ const InfoDatabase = (search) => { plant: latin, path: zoomArtLink, }; - await axios.post( - urls.uploadFeatureArtSingle, - newFeature, - { - headers: { - "Content-Type": "multipart/form-data", - }, + await axios.post(urls.uploadFeatureArtSingle, newFeature, { + headers: { + "Content-Type": "multipart/form-data", }, - ); + }); setFeatureBtnMsg("Success"); setTimeout(() => setFeatureBtnMsg("Submitted!"), 1000); setTimeout(() => setFeatureBtnMsg("Feature"), 1000); @@ -114,15 +110,11 @@ const InfoDatabase = (search) => { plant: latin, path: zoomPicLink, }; - await axios.post( - urls.uploadFeatureSingle, - newFeature, - { - headers: { - "Content-Type": "multipart/form-data", - }, + await axios.post(urls.uploadFeatureSingle, newFeature, { + headers: { + "Content-Type": "multipart/form-data", }, - ); + }); setFeatureBtnMsg("Success"); setTimeout(() => setFeatureBtnMsg("Submitted!"), 1000); setTimeout(() => setFeatureBtnMsg("Feature"), 1000); @@ -254,10 +246,9 @@ const InfoDatabase = (search) => { const startTime = Date.now(); try { - const response = await axios.post( - urls.syncBirdInfo, - { postName: searchName }, - ); + const response = await axios.post(urls.syncBirdInfo, { + postName: searchName, + }); const endTime = Date.now(); const loadTime = endTime - startTime; @@ -300,13 +291,12 @@ const InfoDatabase = (search) => { assignPicPaths(response.data.photographs); setArts(response.data.arts); setOtherNames(response.data.resultPost[0].otherNames || ""); - setStageChar([response.data.resultPost[0].juvChar, - response.data.resultPost[0].subChar, - response.data.resultPost[0].mAdultChar, - response.data.resultPost[0].fAdultChar]) - - - + setStageChar([ + response.data.resultPost[0].juvChar, + response.data.resultPost[0].subChar, + response.data.resultPost[0].mAdultChar, + response.data.resultPost[0].fAdultChar, + ]); } catch (error) { console.log(error); } finally { @@ -353,10 +343,9 @@ const InfoDatabase = (search) => { const sendName = getName; setSearchName(sendName); try { - const response = await axios.post( - urls.syncBirdInfo, - { postName: sendName }, - ); + const response = await axios.post(urls.syncBirdInfo, { + postName: sendName, + }); setLatin(response.data.resultPost[0].latinName); setName( response.data.resultPost[0].commonName + @@ -382,15 +371,12 @@ const InfoDatabase = (search) => { assignPicPaths(response.data.photographs); setArts(response.data.arts); setOtherNames(response.data.resultPost[0].otherNames || ""); - setStageChar([response.data.resultPost[0].juvChar, - response.data.resultPost[0].subChar, - response.data.resultPost[0].mAdultChar, - response.data.resultPost[0].fAdultChar]) - - - - - + setStageChar([ + response.data.resultPost[0].juvChar, + response.data.resultPost[0].subChar, + response.data.resultPost[0].mAdultChar, + response.data.resultPost[0].fAdultChar, + ]); } catch (error) { console.log(error); } @@ -411,7 +397,6 @@ const InfoDatabase = (search) => { FemaleAdult: [], }; - picPaths.forEach(({ season, path, code, takenBy, time, location }) => { if (!paths[season]) { paths[season] = { path: [], code: [] }; @@ -428,13 +413,12 @@ const InfoDatabase = (search) => { // setDisplayedChar(displayIndexes.forEach((i)=> {charList.push(stageChar[i])})); - /* spring: juv summer: subadult autumn: maleadult winter:femaleadult */ setSpringPathsArray(paths.Juvenile); setSummerPathsArray(paths.Subadult); setAutumnPathsArray(paths.MaleAdult); setWinterPathsArray(paths.FemaleAdult); - + setSpringInfo(info.Juvenile); setSummerInfo(info.Subadult); setAutumnInfo(info.MaleAdult); @@ -454,7 +438,7 @@ const InfoDatabase = (search) => { seasonLeft, seasonInfo, index, - text + text, }) { if (seasonPaths?.path?.length === 0) { return null; @@ -465,9 +449,10 @@ const InfoDatabase = (search) => { return (
{ -
+
{/* Characteristics: */} -

- Characteristics: -

-
+

Characteristics:

+

{stageChar[index]} {/* placehodler */} @@ -643,7 +626,9 @@ const InfoDatabase = (search) => { alignItems: "center", }} > -

Diet and Foraging 主要食谱与觅食行为:

+

+ Diet and Foraging 主要食谱与觅食行为: +

{diet}

)} @@ -671,7 +656,9 @@ const InfoDatabase = (search) => { alignItems: "center", }} > -

Movements and Migration 迁徙行为:

+

+ Movements and Migration 迁徙行为: +

{migration}

)} @@ -772,14 +759,17 @@ const InfoDatabase = (search) => {

Setting: {artInfoArray[artsIndex].setting}

- {artPathsArray.length>1? - :<>} + {artPathsArray.length > 1 ? ( + + ) : ( + <> + )}
)} @@ -814,7 +804,6 @@ const InfoDatabase = (search) => { index={0} seasonCheck={springCheck} seasonIndex={springPicsArrayIndex} - seasonLeft={springLeftover} seasonInfo={springInfo} /> diff --git a/client/src/components/CreationPaintings.js b/client/src/components/CreationPaintings.js index 1f3cf41..e160438 100644 --- a/client/src/components/CreationPaintings.js +++ b/client/src/components/CreationPaintings.js @@ -1,11 +1,12 @@ import axios from "../api/http"; -import urls, { mediaUrl } from "../tools/url"; +import urls, { responsiveMediaProps } from "../tools/url"; import React, { useState, useEffect } from "react"; import SubBox from "./SubBox.js"; import "../styles/creationPaint.css"; import { ReactComponent as PreviousIcon } from "../src/buttons/caret-back-outline.svg"; import { ReactComponent as NextIcon } from "../src/buttons/caret-forward-outline.svg"; import styles from "../styles/galleryDatabase.module.css"; +import MediaImage from "./MediaImage.js"; const CreationPaintings = ({ handleGets, handleView, onDataLoad }) => { const [pics, setPics] = useState([]); @@ -15,7 +16,7 @@ const CreationPaintings = ({ handleGets, handleView, onDataLoad }) => { // const [numOfPages, setNumOfPages] = useState(0); const [subPgIndex, setSubPgIndex] = useState(1); const [currentDisplayIndexes, setCurrentDisplayIndexes] = useState([ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ]); const [currentOrder, setCurrentOrder] = useState(false); const [orderIsBold1, setOrderIsBold1] = useState(false); @@ -34,7 +35,7 @@ const CreationPaintings = ({ handleGets, handleView, onDataLoad }) => { if (parts.length !== 3) { // Handle potential bad data by returning the earliest possible date - return new Date(0); + return new Date(0); } // 2. Parse parts into numbers @@ -49,12 +50,9 @@ const CreationPaintings = ({ handleGets, handleView, onDataLoad }) => { } useEffect(() => { - const fetchData = async () => { try { - const response = await axios.get( - urls.creationDocumentary, - ); + const response = await axios.get(urls.creationDocumentary); const allDisplays = response.data.allDisplays; allDisplays.sort((a, b) => { // Get the Date objects for comparison @@ -66,9 +64,12 @@ const CreationPaintings = ({ handleGets, handleView, onDataLoad }) => { return dateA - dateB; }); setDisplayObjectList(allDisplays); - setCurrentDisplayIndexes(Array.from(Array(allDisplays.length).keys())) + setCurrentDisplayIndexes(Array.from(Array(allDisplays.length).keys())); if (allDisplays && allDisplays.length > 0) { - const topThreeDisplays = allDisplays.slice(allDisplays.length-3, allDisplays.length); + const topThreeDisplays = allDisplays.slice( + allDisplays.length - 3, + allDisplays.length, + ); const newTopPics = []; const newTopArts = []; const newTopBys = []; @@ -89,7 +90,6 @@ const CreationPaintings = ({ handleGets, handleView, onDataLoad }) => { setTopLoc(newTopLoc); setTopTimes(newTopTimes); - // Notify parent that we have data onDataLoad(true); } else { @@ -107,8 +107,10 @@ const CreationPaintings = ({ handleGets, handleView, onDataLoad }) => { const numOfPages = Math.ceil(displayObjectList.length / itemsPerPage); const startIndex = (subPgIndex - 1) * itemsPerPage; - const currentItems = displayObjectList.slice(startIndex, startIndex + itemsPerPage); - + const currentItems = displayObjectList.slice( + startIndex, + startIndex + itemsPerPage, + ); const setSubIndex = (currentIndex) => { setSubPgIndex(currentIndex); @@ -120,7 +122,12 @@ const CreationPaintings = ({ handleGets, handleView, onDataLoad }) => { // (index - 1) * 6 + 4, // (index - 1) * 6 + 5, // ]); - setCurrentDisplayIndexes(Array.from({length:itemsPerPage}, (_, index)=> (currentIndex - 1) * itemsPerPage + index)); + setCurrentDisplayIndexes( + Array.from( + { length: itemsPerPage }, + (_, index) => (currentIndex - 1) * itemsPerPage + index, + ), + ); }; function changeOrder(order) { @@ -129,7 +136,7 @@ const CreationPaintings = ({ handleGets, handleView, onDataLoad }) => { setOrderIsBold2(!order); setCurrentOrder(!currentOrder); setDisplayObjectList(displayObjectList.reverse()); - if (displayObjectList){ + if (displayObjectList) { setSubIndex(1); } } @@ -179,14 +186,18 @@ const CreationPaintings = ({ handleGets, handleView, onDataLoad }) => { - pic
- art @@ -224,22 +235,23 @@ const CreationPaintings = ({ handleGets, handleView, onDataLoad }) => { Oldest -
-
+
+
{numOfPages > 0 && Array.from({ length: numOfPages }, (_, index) => ( - ))}

{Array.from( - { length: itemsPerPage / 2}, + { length: itemsPerPage / 2 }, (_, index) => (displayObjectList[currentDisplayIndexes[2 * index]] || displayObjectList[currentDisplayIndexes[2 * index + 1]]) && ( diff --git a/client/src/components/GalleryDatabase.js b/client/src/components/GalleryDatabase.js index a5476f7..d50905a 100755 --- a/client/src/components/GalleryDatabase.js +++ b/client/src/components/GalleryDatabase.js @@ -1,5 +1,5 @@ import axios from "../api/http"; -import urls, { mediaUrl } from "../tools/url"; +import urls, { mediaUrl, responsiveMediaProps } from "../tools/url"; import React, { useState, useEffect, useMemo } from "react"; import styles from "../styles/galleryDatabase.module.css"; import ArrowIcon from "../src/buttons/arrow.svg"; @@ -8,18 +8,18 @@ import MediaImage from "./MediaImage.js"; const GalleryDatabase = (props) => { const seasons = useMemo(() => ["spring", "summer", "autumn", "winter"], []); const month2NumMap = { - 'Jan':'01/', - 'Feb':'02/', - 'Mar':'03/', - 'Apr':'04/', - 'May':'05/', - 'Jun':'06/', - 'Jul':'07/', - 'Aug':'08/', - 'Sep':'09/', - 'Oct':'10/', - 'Nov':'11/', - 'Dec':'12/', + Jan: "01/", + Feb: "02/", + Mar: "03/", + Apr: "04/", + May: "05/", + Jun: "06/", + Jul: "07/", + Aug: "08/", + Sep: "09/", + Oct: "10/", + Nov: "11/", + Dec: "12/", }; // const [availableSeason, setAvailableSeason] = useState(""); var availableSeason = ""; @@ -40,15 +40,12 @@ const GalleryDatabase = (props) => { const [seasonMonthMap, setSeasonMonthMap] = useState({}); // also set allSeasonPics so you can page later const [allSeasonPics, setAllSeasonPics] = useState({}); - - // Which season is selected (e.g. "spring", or "" if none yet) const [selectedSeason, setSelectedSeason] = useState(""); // Which month‐year is selected within that season (e.g. "Mar 2025") const [selectedMonth, setSelectedMonth] = useState(""); - useEffect(() => { const springBtn = document.getElementById("springBtn"); if (springBtn) { @@ -114,14 +111,14 @@ const GalleryDatabase = (props) => { const plant = props.customKey; const getPics = async () => { try { - const response = await axios.post( - urls.getPics, - { plant: plant }, - ); + const response = await axios.post(urls.getPics, { plant: plant }); let newArray = []; seasons.forEach((season, index) => { newArray[index] = response.data[`${season}Pics`]; - if(response.data[`${season}Pics`].length>0 && availableSeason===""){ + if ( + response.data[`${season}Pics`].length > 0 && + availableSeason === "" + ) { availableSeason = season; } }); @@ -129,20 +126,19 @@ const GalleryDatabase = (props) => { const seasonMonthMap = seasons.reduce((acc, season, i) => { const pics = newArray[i]; const monthMap = {}; - pics.forEach(pic => { + pics.forEach((pic) => { // assume pic.time === "Day Mon DD YYYY …" const [, mon, , year] = pic.time.split(" "); const key = `${mon} ${year}`; if (!monthMap[key]) monthMap[key] = []; monthMap[key].push(pic); - }); acc[season] = monthMap; return acc; }, {}); - + setSelectedSeason(availableSeason); - setSelectedMonth((Object.keys(seasonMonthMap[availableSeason])[0] || "")); + setSelectedMonth(Object.keys(seasonMonthMap[availableSeason])[0] || ""); setPics(newArray); sortPicMonth(newArray.flat()); @@ -155,7 +151,6 @@ const GalleryDatabase = (props) => { getPics(); }, [props.customKey, seasons]); - const handleBack = () => { props.handleBack(props.customKey); }; @@ -166,15 +161,11 @@ const GalleryDatabase = (props) => { plant: " " + props.customKey, path: zoomPicLink, }; - await axios.post( - urls.uploadFeatureSingle, - newFeature, - { - headers: { - "Content-Type": "multipart/form-data", - }, + await axios.post(urls.uploadFeatureSingle, newFeature, { + headers: { + "Content-Type": "multipart/form-data", }, - ); + }); } catch (error) { console.log(error); } @@ -183,14 +174,11 @@ const GalleryDatabase = (props) => { useEffect(() => { const fetchUserInfo = async () => { try { - const response = await axios.get( - urls.userInfo, - { - headers: { - Authorization: `${localStorage.getItem("askanything")}`, - }, + const response = await axios.get(urls.userInfo, { + headers: { + Authorization: `${localStorage.getItem("askanything")}`, }, - ); + }); setUsername(response.data.username); setAdmin(response.data.admin); } catch (error) { @@ -200,7 +188,6 @@ const GalleryDatabase = (props) => { // setSelectedSeason(availableSeason); fetchUserInfo(); }, []); - // setSelectedMonth((Object.keys([s])[0] || "")); @@ -208,7 +195,6 @@ const GalleryDatabase = (props) => {
-

Image Gallery 图库

*/}
- {seasons.map(s => ( + {seasons.map((s) => ( <> {s != "winter" &&
} - ))}
@@ -281,7 +266,7 @@ const GalleryDatabase = (props) => { setCurIndex(0); }} > - {month2NumMap[m.slice(0,3)]+m.slice(4,8)} + {month2NumMap[m.slice(0, 3)] + m.slice(4, 8)} ))} @@ -320,10 +305,6 @@ const GalleryDatabase = (props) => { }, [])}
*/} - - - - {/* {pics.map( (pic, index) => displays[index] && @@ -375,22 +356,26 @@ const GalleryDatabase = (props) => { ), )} */} - {selectedSeason && selectedMonth && (() => { - const picsToShow = seasonMonthMap[selectedSeason][selectedMonth] || []; - const pageSize = 12; - const start = curIndex * pageSize; - const end = start + pageSize; - return ( - // - <> + {selectedSeason && + selectedMonth && + (() => { + const picsToShow = + seasonMonthMap[selectedSeason][selectedMonth] || []; + const pageSize = 12; + const start = curIndex * pageSize; + const end = start + pageSize; + return ( + // + <>
{picsToShow .slice(curIndex * 12, (curIndex + 1) * 12) .map((p, index) => (
{
)} - ); - })()} + ); + })()} {/* {curMode === "month" && monthPics.map( diff --git a/client/src/components/HomeDatabase.js b/client/src/components/HomeDatabase.js index 04972ce..1bf408e 100755 --- a/client/src/components/HomeDatabase.js +++ b/client/src/components/HomeDatabase.js @@ -1,5 +1,5 @@ import axios from "../api/http"; -import urls, { mediaUrl } from "../tools/url"; +import urls, { responsiveMediaProps } from "../tools/url"; import { Link } from "react-router-dom"; import React, { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; @@ -23,9 +23,7 @@ const DatabaseTwo = ({ handleGet, setLoading }) => { useEffect(() => { const getDb2Pic = async () => { try { - const response = await axios.get( - urls.getDb2Pic, - ); + const response = await axios.get(urls.getDb2Pic); setPics(response.data.pics); } catch (error) { console.log(error); @@ -88,9 +86,8 @@ const DatabaseTwo = ({ handleGet, setLoading }) => { Switch to Bird Database 切换至鸟类检索数据库 -
- + {
- # of Plant Species Recorded in Database -
+ # of Plant Species Recorded in Database +
目前可检索种数
@@ -134,11 +131,13 @@ const DatabaseTwo = ({ handleGet, setLoading }) => { return (
} - loading="eager" + loading={index === 0 ? "eager" : "lazy"} + fetchPriority={index === 0 ? "high" : "auto"} alt="plant" className="picImg" onClick={() => diff --git a/client/src/components/InfoDatabase.js b/client/src/components/InfoDatabase.js index d15307c..aa68991 100755 --- a/client/src/components/InfoDatabase.js +++ b/client/src/components/InfoDatabase.js @@ -1,5 +1,5 @@ import axios from "../api/http"; -import urls, { mediaUrl } from "../tools/url"; +import urls, { mediaUrl, responsiveMediaProps } from "../tools/url"; import { useNavigate } from "react-router-dom"; import { ReactComponent as PreviousIcon } from "../src/buttons/caret-back-outline.svg"; import { ReactComponent as NextIcon } from "../src/buttons/caret-forward-outline.svg"; @@ -73,15 +73,11 @@ const InfoDatabase = (search) => { plant: latin, path: zoomArtLink, }; - await axios.post( - urls.uploadFeatureArtSingle, - newFeature, - { - headers: { - "Content-Type": "multipart/form-data", - }, + await axios.post(urls.uploadFeatureArtSingle, newFeature, { + headers: { + "Content-Type": "multipart/form-data", }, - ); + }); setFeatureBtnMsg("Success"); setTimeout(() => setFeatureBtnMsg("Submitted!"), 1000); setTimeout(() => setFeatureBtnMsg("Feature"), 1000); @@ -97,15 +93,11 @@ const InfoDatabase = (search) => { plant: latin, path: zoomPicLink, }; - await axios.post( - urls.uploadFeatureSingle, - newFeature, - { - headers: { - "Content-Type": "multipart/form-data", - }, + await axios.post(urls.uploadFeatureSingle, newFeature, { + headers: { + "Content-Type": "multipart/form-data", }, - ); + }); setFeatureBtnMsg("Success"); setTimeout(() => setFeatureBtnMsg("Submitted!"), 1000); setTimeout(() => setFeatureBtnMsg("Feature"), 1000); @@ -230,10 +222,9 @@ const InfoDatabase = (search) => { const startTime = Date.now(); try { - const response = await axios.post( - urls.syncPlantInfo, - { postName: searchName }, - ); + const response = await axios.post(urls.syncPlantInfo, { + postName: searchName, + }); const endTime = Date.now(); const loadTime = endTime - startTime; @@ -314,10 +305,9 @@ const InfoDatabase = (search) => { const sendName = getName; setSearchName(sendName); try { - const response = await axios.post( - urls.syncPlantInfo, - { postName: sendName }, - ); + const response = await axios.post(urls.syncPlantInfo, { + postName: sendName, + }); setLatin(response.data.resultPost[0].latinName); setName( response.data.resultPost[0].commonName + @@ -395,9 +385,10 @@ const InfoDatabase = (search) => { return (
{

Setting: {artInfoArray[artsIndex].setting}

- {artPathsArray.length>1? - :<>} + {artPathsArray.length > 1 ? ( + + ) : ( + <> + )}
)} diff --git a/client/src/components/MediaImage.js b/client/src/components/MediaImage.js index 7ea7400..33c9b41 100644 --- a/client/src/components/MediaImage.js +++ b/client/src/components/MediaImage.js @@ -1,5 +1,8 @@ import React, { useEffect, useRef, useState } from "react"; +const normalizeFallbacks = (fallbackSrc) => + (Array.isArray(fallbackSrc) ? fallbackSrc : [fallbackSrc]).filter(Boolean); + const MediaImage = ({ src, fallbackSrc, @@ -11,23 +14,28 @@ const MediaImage = ({ ...imageProps }) => { const [currentSrc, setCurrentSrc] = useState(src); + const [currentSrcSet, setCurrentSrcSet] = useState(imageProps.srcSet); const [failed, setFailed] = useState(!src); - const fallbackAttempted = useRef(false); + const fallbackIndex = useRef(0); + const fallbacks = normalizeFallbacks(fallbackSrc); + const fallbackKey = fallbacks.join("\n"); useEffect(() => { - fallbackAttempted.current = false; + fallbackIndex.current = 0; setCurrentSrc(src); + setCurrentSrcSet(imageProps.srcSet); setFailed(!src); - }, [fallbackSrc, src]); + }, [fallbackKey, imageProps.srcSet, src]); const handleError = (event) => { - if ( - !fallbackAttempted.current && - fallbackSrc && - fallbackSrc !== currentSrc - ) { - fallbackAttempted.current = true; - setCurrentSrc(fallbackSrc); + const nextFallback = fallbacks + .slice(fallbackIndex.current) + .find((candidate) => candidate !== currentSrc); + + if (nextFallback) { + fallbackIndex.current = fallbacks.indexOf(nextFallback) + 1; + setCurrentSrcSet(undefined); + setCurrentSrc(nextFallback); return; } @@ -43,6 +51,7 @@ const MediaImage = ({ {alt} { act(() => image.dispatchEvent(new Event("error", { bubbles: true }))); expect(container.querySelector("img")).toBeNull(); - expect(container.querySelector('[data-testid="missing-image"]')).not.toBeNull(); + expect( + container.querySelector('[data-testid="missing-image"]'), + ).not.toBeNull(); }); test("defaults details to lazy loading while allowing eager images", () => { @@ -58,3 +60,33 @@ test("defaults details to lazy loading while allowing eager images", () => { image = container.querySelector("img"); expect(image.getAttribute("loading")).toBe("eager"); }); + +test("clears responsive candidates and tries each legacy fallback", () => { + act(() => { + root.render( + , + ); + }); + + let image = container.querySelector("img"); + expect(image.getAttribute("srcset")).toContain("/small.webp"); + + act(() => image.dispatchEvent(new Event("error", { bubbles: true }))); + image = container.querySelector("img"); + expect(image.getAttribute("src")).toBe( + "/public/compressed/plantspic/example.jpg", + ); + expect(image.getAttribute("srcset")).toBe(null); + + act(() => image.dispatchEvent(new Event("error", { bubbles: true }))); + image = container.querySelector("img"); + expect(image.getAttribute("src")).toBe("/public/plantspic/example.jpg"); +}); diff --git a/client/src/components/SubBox.js b/client/src/components/SubBox.js index 1071d71..bfbea5f 100644 --- a/client/src/components/SubBox.js +++ b/client/src/components/SubBox.js @@ -1,9 +1,10 @@ import React from "react"; -import { mediaUrl } from "../tools/url"; +import { responsiveMediaProps } from "../tools/url"; import "../styles/subBox.css"; import { useNavigate } from "react-router-dom"; import { useContext } from "react"; import { UserContext } from "../UserContext"; +import MediaImage from "./MediaImage.js"; const SubBox = ({ displayObjectList, @@ -19,16 +20,21 @@ const SubBox = ({ navigate(`/search/${plant.replace(" ", "_")}`); }; const date1 = displayObjectList[currentDisplayIndexes[index]].artDate; - const date2 =displayObjectList[currentDisplayIndexes[index + 1]] ?displayObjectList[currentDisplayIndexes[index+1]].artDate : ""; //displayObjectList[currentDisplayIndexes[index+1]].artDate; + const date2 = displayObjectList[currentDisplayIndexes[index + 1]] + ? displayObjectList[currentDisplayIndexes[index + 1]].artDate + : ""; //displayObjectList[currentDisplayIndexes[index+1]].artDate; return (
{displayObjectList[currentDisplayIndexes[index]] && (
- art {admin && ( @@ -105,18 +111,20 @@ const SubBox = ({

{displayObjectList[currentDisplayIndexes[index]].date ? "" + - `${date1.slice(0,2)}-${date1.slice(3,5)}-${date1.slice(6,8)}` - // `${date.slice(5,7)}-${date.slice(8,10)}-${date.slice(2,4)}` + `${date1.slice(0, 2)}-${date1.slice(3, 5)}-${date1.slice(6, 8)}` + : // `${date.slice(5,7)}-${date.slice(8,10)}-${date.slice(2,4)}` // displayObjectList[currentDisplayIndexes[index + 1]].date.slice(0,10) - : ""} + ""}

-
{/* Art Info */}
- pic {admin && ( @@ -144,9 +152,12 @@ const SubBox = ({ {displayObjectList[currentDisplayIndexes[index + 1]] ? (
- art {admin && ( @@ -232,17 +243,19 @@ const SubBox = ({

{displayObjectList[currentDisplayIndexes[index + 1]].date ? "" + - `${date2.slice(0,2)}-${date2.slice(3,5)}-${date2.slice(6,8)}` - // `${date.slice(5,7)}-${date.slice(8,10)}-${date.slice(2,4)}` + `${date2.slice(0, 2)}-${date2.slice(3, 5)}-${date2.slice(6, 8)}` + : // `${date.slice(5,7)}-${date.slice(8,10)}-${date.slice(2,4)}` // displayObjectList[currentDisplayIndexes[index + 1]].date.slice(0,10) - : ""} + ""}

-
- pic {admin && ( @@ -269,7 +282,9 @@ const SubBox = ({
- ):
} + ) : ( +
+ )}
); }; diff --git a/client/src/pages/Home.js b/client/src/pages/Home.js index 6239483..a4c221b 100755 --- a/client/src/pages/Home.js +++ b/client/src/pages/Home.js @@ -1,6 +1,6 @@ import React, { useEffect, useState } from "react"; import axios from "../api/http"; -import urls, { mediaUrl } from "../tools/url"; +import urls, { responsiveMediaProps } from "../tools/url"; import { ReactComponent as PreviousIcon } from "../src/buttons/caret-back-outline.svg"; import { ReactComponent as NextIcon } from "../src/buttons/caret-forward-outline.svg"; import styles from "../styles/home.module.css"; @@ -74,9 +74,7 @@ const Home = ({ handleGets }) => { const fetchData = async () => { try { setLoading(true); - const response = await axios.get( - urls.userInfo, - ); + const response = await axios.get(urls.userInfo); setUsername(response.data.username); setAdmin(response.data.admin); @@ -182,7 +180,7 @@ const Home = ({ handleGets }) => {
- +