PhotoShare: Full-Stack SPA with Per-Photo Visibility Control

Overview

PhotoShare is a full-stack single-page application built as the capstone project for Stanford CS 142 (Web Applications), implementing a social photo sharing platform with per-photo granular visibility control via user whitelists. The system combines a React 16 SPA (Material-UI components, HashRouter client-side routing, Axios HTTP client) with an Express 4 REST API (15+ endpoints, session-based authentication, Multer in-memory file uploads) backed by MongoDB via Mongoose ODM (embedded comment sub-documents, denormalized activity records). The authentication layer uses SHA-1 password hashing with per-user random salts (8-byte crypto.randomBytes), while photo access control enforces visibility whitelists across all API endpoints including activity feed redaction — blanking photo thumbnails in activity entries for photos the requesting user cannot see. Cross-component data synchronization uses a boolean flip propagation pattern as a lightweight alternative to Redux. The project includes a 36-test Mocha suite covering password hashing, API authorization, session management, CRUD operations, and file uploads.

System Architecture

┌──────────────────────────────────────────────────────────┐
│                    CLIENT TIER                            │
│  React 16.5 + Material-UI 4.9 + React Router 5 (Hash)   │
│  Webpack 4 → compiled/photoShare.bundle.js               │
├──────────────────────────────────────────────────────────┤
│                   SERVER TIER                             │
│  Express 4.16 + express-session + Multer + body-parser   │
│  webServer.js (15+ REST endpoints, port 3000)            │
├──────────────────────────────────────────────────────────┤
│                   DATA TIER                               │
│  MongoDB via Mongoose 5.9 ODM                             │
│  Collections: Users, Photos, Activities, SchemaInfos     │
└──────────────────────────────────────────────────────────┘

The application operates as a client-side SPA served from a single photo-share.html entry point. React renders into #photoshareapp, all routing is handled client-side via HashRouter (URLs like #/users/:userId, #/photos/:userId), and every data operation flows through Axios calls to the Express REST API.

Database Schema (Mongoose ODM)

Entity Relationships

┌──────────┐       ┌──────────────┐       ┌──────────────┐
│   User   │──1:N──│    Photo     │──1:N──│   Comment    │
│          │       │              │       │  (embedded)  │
│ _id      │       │ _id          │       │ _id          │
│ first_   │       │ file_name    │       │ comment      │
│ last_    │       │ date_time    │       │ date_time    │
│ location │       │ user_id ─────│───┐   │ user_id ────►│── User
│ login_   │       │ comments[]   │   │   └──────────────┘
│ password │       │ use_vis_list │   │
│ salt     │       │ vis_list[] ──│───┘── [User._id, ...]
└──────────┘       └──────────────┘
      │ 1:N
      ▼
┌──────────────┐
│   Activity   │
│ user_id      │
│ activity_type│   ("User logging in.", "A photo upload.",
│ date_time    │    "A comment added.", "User registering.",
│ photo_file   │    "User logging out.")
│ photo_user   │   ← denormalized owner name
└──────────────┘

Comments are embedded within Photo documents as Mongoose sub-documents — a denormalized design optimized for read-heavy photo viewing where comments are always fetched alongside their parent photo. Photo visibility uses a dual-field ACL: a boolean toggle (use_visibility_list) and an array of allowed ObjectIds (visibility_list). When the toggle is false, the photo is public.

Photo Schema with Embedded Comments

var commentSchema = new mongoose.Schema({
    comment: String,
    date_time: {type: Date, default: Date.now},
    user_id: mongoose.Schema.Types.ObjectId,
});

var photoSchema = new mongoose.Schema({
    file_name: String,
    date_time: {type: Date, default: Date.now},
    user_id: mongoose.Schema.Types.ObjectId,
    comments: [commentSchema],                              // Embedded sub-documents
    use_visibility_list: {type: Boolean, default: false},
    visibility_list: [mongoose.Schema.Types.ObjectId],      // Whitelist of allowed viewers
});

Authentication System

SHA-1 Salted Password Hashing

function makePasswordEntry(clearTextPassword) {
    var salt = crypto.randomBytes(8).toString('hex');  // 16 hex chars = 8 bytes entropy
    var hash = crypto.createHash('sha1').update(clearTextPassword + salt).digest('hex');
    return { salt: salt, hash: hash };  // 40 hex chars (160-bit SHA-1 digest)
}

function doesPasswordMatch(hash, salt, clearTextPassword) {
    var computedHash = crypto.createHash('sha1').update(clearTextPassword + salt).digest('hex');
    return computedHash === hash;
}

Each user’s password is stored as a 40-character SHA-1 digest with a unique 16-character hex salt generated via crypto.randomBytes(). Password fields (password_digest, salt) are explicitly excluded from all API responses — the login endpoint deletes them from the response object, and user detail endpoints use Mongoose .select() projection.

Session-Based Authentication

app.use(session({secret: 'secretKey', resave: false, saveUninitialized: false}));

Server-side sessions via express-session store login_name and user_id after successful authentication. Every protected endpoint begins with an inline session check (if (!request.session.login_name) → 401). The login flow queries User.findOne({login_name}), verifies the password hash, creates a “User logging in.” Activity, and returns the user object with sensitive fields stripped.

Per-Photo Visibility Control

The most technically interesting feature — granular per-photo access control enforced across all API endpoints:

Upload-Time ACL Configuration

The PhotoUpload component presents a radio group (public/restricted) and a checkbox list of all registered users. On upload, useVisibilityList (boolean) and visibilityList (JSON-stringified ObjectId array) are sent alongside the multipart file data.

Server-Side Visibility Enforcement

// Applied on /photosOfUser/:id, /mostRecentPhoto/:id, /photoWithMostComment/:id
var photoListVisible = photoList.filter(photo => {
    return !photo.use_visibility_list ||                                    // Public photo
           String(request.session.user_id) === String(photo.user_id) ||     // Owner always sees
           photo.visibility_list.map(String).indexOf(
               String(request.session.user_id)) !== -1;                     // On whitelist
});

Visibility is enforced on six endpoints: photo listing, most recent photo, most commented photo, comment submission (returns 403 for unauthorized visibility), activity feed, and per-user last activity. Visibility fields (use_visibility_list, visibility_list) are stripped from all API responses to prevent information leakage.

Activity Feed Redaction

The /recentActivities endpoint performs a batch lookup of photos referenced in activity entries, then blanks out photo_file_name for any photos the current user cannot see — preventing restricted photo thumbnails from leaking through the activity feed.

File Upload Pipeline

var processFormBody = multer({storage: multer.memoryStorage()}).single('uploadedphoto');

Multer with in-memory storage buffers the uploaded file as a Buffer object. The upload handler validates the file (existence, non-empty originalname, non-zero size), generates a unique filename ("U" + Date.now() + originalFilename), and writes the buffer to ./images/ via fs.writeFile(). Photos are served statically via express.static(__dirname).

React SPA Frontend

Component Hierarchy

PhotoShare (Root) ─── auth state, flip triggers, route config
├── TopBar                     [AppBar, logout, upload button, activity link]
├── UserList                   [Sidebar: user names + per-user last activity]
│   ├── UserLink               [Navigable link to user profile]
│   └── LastActivity → Activity [Most recent activity per user]
└── [Main Content — switched by HashRouter]
    ├── UserDetail             [Profile + most-recent & most-commented photos]
    ├── UserPhotos             [Gallery with comment forms per photo]
    │   └── Photo → Comment    [Card with image, timestamp, comments]
    ├── LoginRegister          [Side-by-side LoginForm + RegisterForm]
    ├── PhotoUpload            [File picker + visibility controls]
    └── ActivityFeed           [5 most recent platform-wide activities]

Client-Side Routing

Route Component Description
/login-register LoginRegister Side-by-side login + 8-field registration
/users/:userId UserDetail Profile info + featured photos
/photos/:userId UserPhotos Photo gallery with inline comment forms
/photoupload PhotoUpload File picker + visibility whitelist
/activityfeed ActivityFeed 5 most recent activities
/ Redirect → own profile (logged in) or login page

Conditional routing: When userIsLoggedIn is false, all routes redirect to /login-register. When true, the login page redirects to the user’s own profile.

Boolean Flip State Propagation

A lightweight alternative to Redux for cross-component data synchronization:

// Root state
this.state = {
    newPhotoUploadedFlipped: false,    // Toggled on photo upload
    newActivityAddedFlipped: false,    // Toggled on any activity
};

// Child components detect the change in componentDidUpdate and re-fetch
componentDidUpdate(prevProps) {
    if (this.props.newPhotoUploadedFlipped !== prevProps.newPhotoUploadedFlipped) {
        this.fetchPhotos();  // Re-fetch from API
    }
}

When a photo is uploaded or activity occurs, the root component toggles a boolean (true→false or false→true). Child components detect the prop change in componentDidUpdate and re-fetch data from the API. This avoids passing actual data upward — only a signal that something changed.

REST API (15+ Endpoints)

Method Endpoint Auth Purpose
GET /user/list Yes All users (projected: _id, first_name, last_name)
GET /user/:id Yes User profile (excludes password fields via .select())
GET /photosOfUser/:id Yes Visibility-filtered photos with populated comments
GET /mostRecentPhoto/:id Yes Most recently uploaded visible photo
GET /photoWithMostComment/:id Yes Photo with most comments (visible only)
GET /recentActivities Yes 5 most recent activities with photo redaction
GET /lastActivity/:id Yes Most recent activity for specific user
POST /admin/login No Authenticate + create session + log activity
POST /admin/logout Yes Destroy session + log activity
POST /commentsOfPhoto/:photo_id Yes Add comment (with visibility check → 403)
POST /photos/new Yes Multer upload + visibility config + log activity
POST /user No Register with salt+hash generation + log activity

Field projection via Mongoose .select() ensures password fields are never exposed. Manual comment population uses async.each with individual User.findOne queries per comment, providing fine-grained field control over the populated user objects.

Activity Tracking System

Five event types are tracked as denormalized Activity documents:

Event Trigger Extra Fields
"User logging in." POST /admin/login
"User logging out." POST /admin/logout
"User registering." POST /user
"A photo upload." POST /photos/new photo_file_name
"A comment added." POST /commentsOfPhoto/:id photo_file_name, photo_user_name

Activities are queried with .sort({date_time: -1}).limit(5) for the platform feed and .sort().limit(1) for per-user last activity. The Activity component renders context-aware styles — larger typography (h6) and icons for the activity feed, smaller (caption) for the user list sidebar.

Material-UI Component Library

Material-UI Component Usage
AppBar + Toolbar Top navigation with logout/upload/activity buttons
Grid + Paper 3/9 responsive sidebar/content split
Card + CardContent + CardMedia Photo cards and comment cards
TextField Login/register inputs and comment boxes
Snackbar + Alert (@material-ui/lab) Toast notifications for success/error feedback
FormControl + RadioGroup + Checkbox Photo visibility controls on upload
List + ListItem + Divider User sidebar list
Typography Variant hierarchy (h4, h5, body1, body2, caption)
Icons CloudUpload, ExitToApp, AddComment, PhotoAlbum, AccountCircle

Color scheme: Comment cards in LavenderBlush (#FFF0F5), activity cards in GhostWhite (#F8F8FF), photo cards in HoneyDew (#F0FFF0).

Test Suite (36 Mocha Tests)

Test File Tests Coverage
cs142passwordTest.js 5 Salt/hash generation, type/length validation, 100-call uniqueness, match/reject
serverApiTest.js 12 Session cookie, user list count + fields, user detail per-user, photo counts + comments, invalid ID → 400
sessionInputApiTest.js 19 401 for unauthorized, login rejection (bad user/password), login+access+logout cycle, comment creation + verification, photo upload + gallery verification, registration + duplicate rejection

Demo

Tech Stack

JavaScript (ES6+), React (16.5), Material-UI (4.9), React Router (5.1), Axios, Express (4.16), express-session, Multer (1.4), body-parser, Mongoose (5.9), MongoDB, async (3.1), Webpack (4), Babel (6), Mocha, Node.js