Commit 1d8b8552 authored by Stefy Spora's avatar Stefy Spora

Initial release v1.0.0 - FetLife Privacy Helper Chrome Extension

- XHR-based privacy automation for FetLife videos and pictures
- Single and bulk processing capabilities with pagination support
- Context menu integration and popup interface
- Support for both direct and username-based URLs
- GPLv3 licensed open source project by sexhack.me
parents
# Chrome Extension Development
*.crx
*.pem
key.pem
# Node modules (if using build tools)
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# IDE and Editor files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Temporary files
*.tmp
*.temp
*.log
# Build artifacts
dist/
build/
\ No newline at end of file
# Changelog
All notable changes to the FetLife Privacy Helper extension will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.0] - 2025-08-30
### Added
- Initial release of FetLife Privacy Helper Chrome extension
- Single privacy update functionality via "Privaxy" button
- Bulk processing with "Run on All Media" button for multiple pages
- Context menu integration with right-click options
- Stop functionality for bulk operations
- XHR-based privacy updates using direct API calls
- Support for both video and picture privacy settings
- URL detection for both direct and username-based FetLife URLs
- Automatic CSRF token handling
- Progress tracking and error reporting
- Comprehensive error handling and recovery
- Rate limiting to prevent server overload
### Technical Features
- Modern XHR/Fetch API implementation
- No DOM manipulation required
- Support for gallery/list page bulk processing
- Automatic pagination handling with "next" link detection
- Canonical API endpoint usage regardless of source URL format
- GPLv3 licensed open source code
### Supported URL Formats
- Direct: `https://fetlife.com/videos/{ID}` and `https://fetlife.com/pictures/{ID}`
- Username: `https://fetlife.com/{username}/videos/{ID}` and `https://fetlife.com/{username}/pictures/{ID}`
### Privacy Settings
- Videos: Sets `only_friends: true`
- Pictures: Sets `content_privacy: "only_friends"`
### Browser Support
- Chrome (Manifest V3)
- Chromium-based browsers
---
**Project Information:**
- Author: Stefy Spora <stefy@sexhack.me>
- Website: [sexhack.me](https://www.sexhack.me) (NSFW)
- Source Code: [git.nexlab.net/sexhackme/fetprivacy](https://git.nexlab.net/sexhackme/fetprivacy)
- License: GNU General Public License v3.0
- Bitcoin Donations: `bc1qcpt2uutqkz4456j5r78rjm3gwq03h5fpwmcc5u`
\ No newline at end of file
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2025 Stefy Spora <stefy@sexhack.me> - sexhack.me
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions
\ No newline at end of file
# FetLife Privacy Helper Chrome Extension
A Chrome browser extension that automates privacy settings on FetLife.com. When activated, it automatically sets picture privacy to "only friends" or video privacy to "True" (only friends) with a single click or processes multiple pages automatically.
**Author:** Stefy Spora <stefy@sexhack.me>
**Website:** [sexhack.me](https://www.sexhack.me) ⚠️ **(NSFW)**
**Source Code:** [git.nexlab.net/sexhackme/fetprivacy](https://git.nexlab.net/sexhackme/fetprivacy)
**License:** GNU General Public License v3.0
**Bitcoin Donations:** `bc1qcpt2uutqkz4456j5r78rjm3gwq03h5fpwmcc5u`
## Features
- **Single Update**: Click the "Privaxy" button in the extension popup
- **Bulk Processing**: Click "Run on All Media" to process multiple pages automatically
- **Context Menu**: Right-click on any FetLife page and select "Privaxy" or "Run on All Media"
- **Automatic Privacy Update**: Finds the Edit button, changes privacy to "only friends", and saves changes
- **Pagination Support**: Automatically follows "next »" links to process multiple pages
- **Stop Control**: Stop bulk processing at any time with the Stop button
- **Progress Tracking**: Shows real-time progress during bulk operations
- **Status Feedback**: Shows success/error messages and notifications
## Installation
1. Download or clone this repository
2. Open Chrome and navigate to `chrome://extensions/`
3. Enable "Developer mode" in the top right corner
4. Click "Load unpacked" and select the extension folder
5. The extension icon should appear in your Chrome toolbar
## Usage
### Method 1: Single Page Update (Extension Popup)
1. Navigate to a FetLife picture or video page
2. Click the extension icon in your Chrome toolbar
3. Click the "Privaxy" button in the popup
4. Wait for the success message
### Method 2: Bulk Processing (Extension Popup)
1. Navigate to a FetLife gallery or list page with multiple media items
2. Click the extension icon in your Chrome toolbar
3. Click the "Run on All Media" button in the popup
4. Watch the progress counter as it processes each page
5. Click "Stop" at any time to halt the process
6. Wait for the completion summary
### Method 3: Context Menu (Single Page)
1. Navigate to a FetLife picture or video page
2. Right-click anywhere on the page
3. Select "Privaxy" from the context menu
4. A notification will show the result
### Method 4: Context Menu (Bulk Processing)
1. Navigate to a FetLife gallery or list page
2. Right-click anywhere on the page
3. Select "Run on All Media" from the context menu
4. Notifications will show progress and completion status
## How It Works
The extension uses a modern XHR-based approach for reliable privacy updates:
1. **URL Detection**: Automatically detects if you're on a video or picture page, supporting both formats:
- Direct URLs: `/videos/{ID}` or `/pictures/{ID}`
- Username URLs: `/{username}/videos/{ID}` or `/{username}/pictures/{ID}`
2. **CSRF Token Extraction**: Finds and uses the page's CSRF token for secure requests
3. **Direct API Calls**: Sends PUT requests directly to FetLife's canonical API endpoints with the appropriate payload:
- **Videos**: `{"video":{"title":"","description":"","only_friends":true,"tag_names":[],"user_tag_ids":[]},"render_flash":true}`
- **Pictures**: `{"picture":{"caption":"","content_privacy":"only_friends","tag_names":[],"user_tag_ids":[]},"render_flash":true}`
4. **Bulk Processing**: For gallery pages, extracts all media IDs and processes them via API calls
5. **No DOM Manipulation**: Bypasses form interactions for faster, more reliable updates
## Requirements
- Chrome browser
- Access to FetLife.com
- Must be logged into FetLife (for CSRF token access)
- Works on individual video/picture pages or gallery/list pages
- Requires edit permissions for the media you want to update
## Files Structure
- `manifest.json` - Extension configuration
- `popup.html` - Extension popup interface
- `popup.js` - Popup functionality
- `content.js` - DOM manipulation script
- `background.js` - Context menu and background tasks
- `icon*.png` - Extension icons
## Troubleshooting
- **"Edit button not found"**: Make sure you're on a picture or video page that you can edit
- **"Neither picture nor video privacy select found"**: The edit form may not have loaded properly, try again
- **"Video privacy 'True' option not found"**: The video privacy options may have changed
- **"Save Changes button not found"**: The page structure may have changed
- **Bulk processing stops unexpectedly**: Check if you've reached the end of available pages or if there's a network issue
- **"No next link found"**: The bulk process has reached the last page or the pagination structure has changed
## Privacy & Security
This extension only works on FetLife.com and only performs the specific privacy actions described. It does not collect, store, or transmit any personal data.
## Contributing
This project is open source under the GPLv3 license. Contributions, bug reports, and feature requests are welcome!
- **Source Code:** [git.nexlab.net/sexhackme/fetprivacy](https://git.nexlab.net/sexhackme/fetprivacy)
- **Issues:** Report bugs and request features via the git repository
- **License:** GNU General Public License v3.0 - see [LICENSE](LICENSE) file
## Support the Project
If you find this extension useful, consider supporting its development:
**Bitcoin Donations:** `bc1qcpt2uutqkz4456j5r78rjm3gwq03h5fpwmcc5u`
## Author & Credits
**Stefy Spora** <stefy@sexhack.me>
[sexhack.me](https://www.sexhack.me) ⚠️ **(NSFW)**
---
*This extension is not affiliated with or endorsed by FetLife. FetLife is a trademark of BitLove Inc.*
\ No newline at end of file
/*
* FetLife Privacy Helper - Background Script
* Copyright (C) 2025 Stefy Spora <stefy@sexhack.me> - sexhack.me
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// Background script for FetLife Privacy Helper
// Handles context menu creation and messaging
chrome.runtime.onInstalled.addListener(() => {
// Create context menu items
chrome.contextMenus.create({
id: 'privaxy',
title: 'Privaxy',
contexts: ['page'],
documentUrlPatterns: ['https://fetlife.com/*']
});
chrome.contextMenus.create({
id: 'run-on-all-media',
title: 'Run on All Media',
contexts: ['page'],
documentUrlPatterns: ['https://fetlife.com/*']
});
console.log('FetLife Privacy Helper extension installed');
});
// Handle context menu clicks
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
if (info.menuItemId === 'privaxy') {
try {
// Execute the privacy update function
const results = await chrome.scripting.executeScript({
target: { tabId: tab.id },
function: executePrivacyUpdateFromContext
});
const result = results[0].result;
// Show notification with result
chrome.notifications.create({
type: 'basic',
iconUrl: 'icon48.png',
title: 'FetLife Privacy Helper',
message: result.success ? result.message : 'Error: ' + result.message
});
} catch (error) {
console.error('Context menu execution error:', error);
chrome.notifications.create({
type: 'basic',
iconUrl: 'icon48.png',
title: 'FetLife Privacy Helper',
message: 'Error: ' + error.message
});
}
} else if (info.menuItemId === 'run-on-all-media') {
try {
// Start bulk processing
chrome.notifications.create({
type: 'basic',
iconUrl: 'icon48.png',
title: 'FetLife Privacy Helper',
message: 'Starting bulk privacy update...'
});
// Execute the bulk privacy update function
const results = await chrome.scripting.executeScript({
target: { tabId: tab.id },
function: executeBulkPrivacyUpdateFromContext
});
} catch (error) {
console.error('Bulk context menu execution error:', error);
chrome.notifications.create({
type: 'basic',
iconUrl: 'icon48.png',
title: 'FetLife Privacy Helper',
message: 'Bulk processing error: ' + error.message
});
}
}
});
// Function to be injected for context menu execution - XHR-based approach
function executePrivacyUpdateFromContext() {
// Function to extract media ID and type from URL
function getMediaInfo() {
const url = window.location.href;
// Handle both direct URLs and username-based URLs
// Direct: https://fetlife.com/videos/123 or https://fetlife.com/pictures/123
// Username: https://fetlife.com/username/videos/123 or https://fetlife.com/username/pictures/123
const videoMatch = url.match(/https:\/\/fetlife\.com\/(?:[^\/]+\/)?videos\/(\d+)/);
const pictureMatch = url.match(/https:\/\/fetlife\.com\/(?:[^\/]+\/)?pictures\/(\d+)/);
if (videoMatch) {
return { type: 'video', id: videoMatch[1] };
} else if (pictureMatch) {
return { type: 'picture', id: pictureMatch[1] };
}
return null;
}
// Function to get CSRF token from the page
function getCSRFToken() {
const metaTag = document.querySelector('meta[name="csrf-token"]');
if (metaTag) {
return metaTag.getAttribute('content');
}
const csrfInput = document.querySelector('input[name="authenticity_token"]');
if (csrfInput) {
return csrfInput.value;
}
return null;
}
// Main XHR-based privacy update
async function updatePrivacy() {
try {
console.log('Starting XHR-based privacy update from context menu...');
// Step 1: Detect media type and ID from URL
const mediaInfo = getMediaInfo();
if (!mediaInfo) {
throw new Error('Not on a video or picture page. Please navigate to a FetLife video or picture URL.');
}
console.log(`Detected ${mediaInfo.type} with ID: ${mediaInfo.id}`);
// Step 2: Get CSRF token
const csrfToken = getCSRFToken();
if (!csrfToken) {
throw new Error('Could not find CSRF token. Please refresh the page and try again.');
}
// Step 3: Prepare payload based on media type
let payload;
if (mediaInfo.type === 'video') {
payload = {
video: {
title: "",
description: "",
only_friends: true,
tag_names: [],
user_tag_ids: []
},
render_flash: true
};
} else {
payload = {
picture: {
caption: "",
content_privacy: "only_friends",
tag_names: [],
user_tag_ids: []
},
render_flash: true
};
}
console.log('Sending XHR request with payload:', payload);
// Step 4: Send XHR PUT request
const response = await fetch(`https://fetlife.com/${mediaInfo.type}s/${mediaInfo.id}`, {
method: 'PUT',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json, text/javascript, */*; q=0.01',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`XHR request failed: ${response.status} ${response.statusText}`);
}
const result = await response.json();
console.log('XHR response:', result);
return {
success: true,
message: `${mediaInfo.type.charAt(0).toUpperCase() + mediaInfo.type.slice(1)} privacy updated to "only friends" successfully!`
};
} catch (error) {
console.error('XHR privacy update failed:', error);
return { success: false, message: error.message };
}
}
return updatePrivacy();
}
// Function for bulk privacy update from context menu - XHR-based approach
function executeBulkPrivacyUpdateFromContext() {
// Helper functions
function getCSRFToken() {
const metaTag = document.querySelector('meta[name="csrf-token"]');
if (metaTag) {
return metaTag.getAttribute('content');
}
const csrfInput = document.querySelector('input[name="authenticity_token"]');
if (csrfInput) {
return csrfInput.value;
}
return null;
}
function extractMediaIdsFromPage() {
const mediaIds = [];
// Look for video links (handle both direct and username-based URLs)
const videoLinks = document.querySelectorAll('a[href*="/videos/"]');
videoLinks.forEach(link => {
const match = link.href.match(/\/(?:[^\/]+\/)?videos\/(\d+)/);
if (match) {
mediaIds.push({ type: 'video', id: match[1], url: link.href });
}
});
// Look for picture links (handle both direct and username-based URLs)
const pictureLinks = document.querySelectorAll('a[href*="/pictures/"]');
pictureLinks.forEach(link => {
const match = link.href.match(/\/(?:[^\/]+\/)?pictures\/(\d+)/);
if (match) {
mediaIds.push({ type: 'picture', id: match[1], url: link.href });
}
});
return mediaIds;
}
async function updateMediaPrivacyById(mediaType, mediaId, csrfToken) {
try {
let payload;
if (mediaType === 'video') {
payload = {
video: {
title: "",
description: "",
only_friends: true,
tag_names: [],
user_tag_ids: []
},
render_flash: true
};
} else {
payload = {
picture: {
caption: "",
content_privacy: "only_friends",
tag_names: [],
user_tag_ids: []
},
render_flash: true
};
}
const response = await fetch(`https://fetlife.com/${mediaType}s/${mediaId}`, {
method: 'PUT',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json, text/javascript, */*; q=0.01',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`XHR request failed: ${response.status} ${response.statusText}`);
}
return { success: true, message: `${mediaType} ${mediaId} updated successfully` };
} catch (error) {
console.error(`Failed to update ${mediaType} ${mediaId}:`, error);
return { success: false, message: error.message };
}
}
function findAndClickNext() {
try {
const nextLinks = Array.from(document.querySelectorAll('a')).filter(link =>
link.textContent.trim().includes('next »') ||
link.textContent.trim().includes('next ›') ||
link.textContent.trim() === 'next'
);
if (nextLinks.length === 0) {
return { hasNext: false };
}
nextLinks[0].click();
return { hasNext: true };
} catch (error) {
return { hasNext: false };
}
}
// Main bulk processing function
async function runBulkUpdate() {
let pageCount = 0;
let successCount = 0;
let errorCount = 0;
let shouldContinue = true;
try {
const csrfToken = getCSRFToken();
if (!csrfToken) {
throw new Error('Could not find CSRF token');
}
while (shouldContinue && pageCount < 100) { // Safety limit
pageCount++;
try {
// Extract all media IDs from current page
const mediaItems = extractMediaIdsFromPage();
if (mediaItems.length === 0) {
// If no media items found, try single page update
const singleResult = await executePrivacyUpdateFromContext();
if (singleResult.success) {
successCount++;
} else {
errorCount++;
}
} else {
// Process all media items on current page
for (const item of mediaItems) {
try {
const result = await updateMediaPrivacyById(item.type, item.id, csrfToken);
if (result.success) {
successCount++;
} else {
errorCount++;
}
// Small delay between requests to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 200));
} catch (error) {
errorCount++;
console.error(`Error updating ${item.type} ${item.id}:`, error);
}
}
}
await new Promise(resolve => setTimeout(resolve, 1500));
const nextResult = findAndClickNext();
if (!nextResult.hasNext) {
shouldContinue = false;
break;
}
await new Promise(resolve => setTimeout(resolve, 2500));
} catch (error) {
errorCount++;
console.error(`Error on page ${pageCount}:`, error);
// Try to continue to next page
try {
const nextResult = findAndClickNext();
if (!nextResult.hasNext) {
shouldContinue = false;
} else {
await new Promise(resolve => setTimeout(resolve, 2500));
}
} catch (nextError) {
shouldContinue = false;
}
}
}
// Send final notification via message to background script
setTimeout(() => {
chrome.runtime.sendMessage({
action: 'showNotification',
title: 'Bulk Processing Complete',
message: `Processed ${pageCount} pages (${successCount} success, ${errorCount} errors)`
});
}, 1000);
return { pageCount, successCount, errorCount };
} catch (error) {
console.error('Bulk processing failed:', error);
return { pageCount: 0, successCount: 0, errorCount: 1 };
}
}
return runBulkUpdate();
}
// Handle messages from popup and notifications
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === 'executePrivacyUpdate') {
// Forward to content script
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
chrome.tabs.sendMessage(tabs[0].id, request, sendResponse);
});
return true;
} else if (request.action === 'showNotification') {
chrome.notifications.create({
type: 'basic',
iconUrl: 'icon48.png',
title: request.title,
message: request.message
});
}
});
\ No newline at end of file
/*
* FetLife Privacy Helper - Content Script
* Copyright (C) 2025 Stefy Spora <stefy@sexhack.me> - sexhack.me
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// Content script for FetLife Privacy Helper
// This script runs on fetlife.com pages and provides XHR-based privacy updates
(function() {
'use strict';
// Function to extract media ID and type from URL
function getMediaInfo() {
const url = window.location.href;
// Handle both direct URLs and username-based URLs
// Direct: https://fetlife.com/videos/123 or https://fetlife.com/pictures/123
// Username: https://fetlife.com/username/videos/123 or https://fetlife.com/username/pictures/123
const videoMatch = url.match(/https:\/\/fetlife\.com\/(?:[^\/]+\/)?videos\/(\d+)/);
const pictureMatch = url.match(/https:\/\/fetlife\.com\/(?:[^\/]+\/)?pictures\/(\d+)/);
if (videoMatch) {
return { type: 'video', id: videoMatch[1] };
} else if (pictureMatch) {
return { type: 'picture', id: pictureMatch[1] };
}
return null;
}
// Function to get CSRF token from the page
function getCSRFToken() {
const metaTag = document.querySelector('meta[name="csrf-token"]');
if (metaTag) {
return metaTag.getAttribute('content');
}
// Fallback: try to find it in a form
const csrfInput = document.querySelector('input[name="authenticity_token"]');
if (csrfInput) {
return csrfInput.value;
}
return null;
}
// Function to get current media data for preserving existing values
async function getCurrentMediaData(type, id) {
try {
const response = await fetch(`https://fetlife.com/${type}s/${id}`, {
method: 'GET',
credentials: 'same-origin',
headers: {
'Accept': 'application/json, text/javascript, */*; q=0.01',
'X-Requested-With': 'XMLHttpRequest'
}
});
if (!response.ok) {
throw new Error(`Failed to fetch ${type} data: ${response.status}`);
}
// For now, we'll use empty defaults since we're only updating privacy
// In a real implementation, you might want to parse the HTML response
// to extract current title/caption and tags
return {
title: '',
caption: '',
description: '',
tag_names: [],
user_tag_ids: []
};
} catch (error) {
console.warn('Could not fetch current media data, using defaults:', error);
return {
title: '',
caption: '',
description: '',
tag_names: [],
user_tag_ids: []
};
}
}
// Main function to execute privacy update via XHR
async function executePrivacyUpdate() {
try {
console.log('Starting XHR-based privacy update...');
// Step 1: Detect media type and ID from URL
const mediaInfo = getMediaInfo();
if (!mediaInfo) {
throw new Error('Not on a video or picture page. Please navigate to a FetLife video or picture URL.');
}
console.log(`Detected ${mediaInfo.type} with ID: ${mediaInfo.id}`);
// Step 2: Get CSRF token
const csrfToken = getCSRFToken();
if (!csrfToken) {
throw new Error('Could not find CSRF token. Please refresh the page and try again.');
}
// Step 3: Get current media data to preserve existing values
const currentData = await getCurrentMediaData(mediaInfo.type, mediaInfo.id);
// Step 4: Prepare payload based on media type
let payload;
if (mediaInfo.type === 'video') {
payload = {
video: {
title: currentData.title,
description: currentData.description,
only_friends: true,
tag_names: currentData.tag_names,
user_tag_ids: currentData.user_tag_ids
},
render_flash: true
};
} else {
payload = {
picture: {
caption: currentData.caption,
content_privacy: "only_friends",
tag_names: currentData.tag_names,
user_tag_ids: currentData.user_tag_ids
},
render_flash: true
};
}
console.log('Sending XHR request with payload:', payload);
// Step 5: Send XHR PUT request
const response = await fetch(`https://fetlife.com/${mediaInfo.type}s/${mediaInfo.id}`, {
method: 'PUT',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json, text/javascript, */*; q=0.01',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`XHR request failed: ${response.status} ${response.statusText}`);
}
const result = await response.json();
console.log('XHR response:', result);
return {
success: true,
message: `${mediaInfo.type.charAt(0).toUpperCase() + mediaInfo.type.slice(1)} privacy updated to "only friends" successfully!`
};
} catch (error) {
console.error('XHR privacy update failed:', error);
return { success: false, message: error.message };
}
}
// Function to extract media IDs from gallery/list pages
function extractMediaIdsFromPage() {
const mediaIds = [];
// Look for video links (handle both direct and username-based URLs)
const videoLinks = document.querySelectorAll('a[href*="/videos/"]');
videoLinks.forEach(link => {
const match = link.href.match(/\/(?:[^\/]+\/)?videos\/(\d+)/);
if (match) {
mediaIds.push({ type: 'video', id: match[1], url: link.href });
}
});
// Look for picture links (handle both direct and username-based URLs)
const pictureLinks = document.querySelectorAll('a[href*="/pictures/"]');
pictureLinks.forEach(link => {
const match = link.href.match(/\/(?:[^\/]+\/)?pictures\/(\d+)/);
if (match) {
mediaIds.push({ type: 'picture', id: match[1], url: link.href });
}
});
return mediaIds;
}
// Function to update privacy for a specific media item by ID
async function updateMediaPrivacyById(mediaType, mediaId) {
try {
// Get CSRF token
const csrfToken = getCSRFToken();
if (!csrfToken) {
throw new Error('Could not find CSRF token');
}
// Get current media data
const currentData = await getCurrentMediaData(mediaType, mediaId);
// Prepare payload
let payload;
if (mediaType === 'video') {
payload = {
video: {
title: currentData.title,
description: currentData.description,
only_friends: true,
tag_names: currentData.tag_names,
user_tag_ids: currentData.user_tag_ids
},
render_flash: true
};
} else {
payload = {
picture: {
caption: currentData.caption,
content_privacy: "only_friends",
tag_names: currentData.tag_names,
user_tag_ids: currentData.user_tag_ids
},
render_flash: true
};
}
// Send XHR PUT request
const response = await fetch(`https://fetlife.com/${mediaType}s/${mediaId}`, {
method: 'PUT',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json, text/javascript, */*; q=0.01',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`XHR request failed: ${response.status} ${response.statusText}`);
}
return { success: true, message: `${mediaType} ${mediaId} updated successfully` };
} catch (error) {
console.error(`Failed to update ${mediaType} ${mediaId}:`, error);
return { success: false, message: error.message };
}
}
// Function to find and click next link
async function findAndClickNext() {
try {
// Look for the "next »" link
const nextLinks = Array.from(document.querySelectorAll('a')).filter(link =>
link.textContent.trim().includes('next »') ||
link.textContent.trim().includes('next ›') ||
link.textContent.trim() === 'next'
);
if (nextLinks.length === 0) {
return { hasNext: false, message: 'No next link found' };
}
// Click the first next link found
nextLinks[0].click();
return { hasNext: true, message: 'Clicked next link' };
} catch (error) {
return { hasNext: false, message: 'Error finding next link: ' + error.message };
}
}
// Bulk processing function - now works on gallery pages
async function executeBulkPrivacyUpdate() {
let pageCount = 0;
let successCount = 0;
let errorCount = 0;
let shouldContinue = true;
try {
while (shouldContinue && pageCount < 100) { // Safety limit
pageCount++;
// Send progress update
chrome.runtime.sendMessage({
action: 'bulkProgress',
pageCount,
successCount,
errorCount
});
try {
// Extract all media IDs from current page
const mediaItems = extractMediaIdsFromPage();
if (mediaItems.length === 0) {
// If no media items found, try single page update
const result = await executePrivacyUpdate();
if (result.success) {
successCount++;
} else {
errorCount++;
}
} else {
// Process all media items on current page
for (const item of mediaItems) {
try {
const result = await updateMediaPrivacyById(item.type, item.id);
if (result.success) {
successCount++;
} else {
errorCount++;
}
// Small delay between requests to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 200));
} catch (error) {
errorCount++;
console.error(`Error updating ${item.type} ${item.id}:`, error);
}
}
}
// Wait before checking for next link
await new Promise(resolve => setTimeout(resolve, 1500));
const nextResult = await findAndClickNext();
if (!nextResult.hasNext) {
shouldContinue = false;
break;
}
// Wait for page to load
await new Promise(resolve => setTimeout(resolve, 2500));
} catch (error) {
errorCount++;
console.error(`Error on page ${pageCount}:`, error);
// Try to continue to next page
try {
const nextResult = await findAndClickNext();
if (!nextResult.hasNext) {
shouldContinue = false;
} else {
await new Promise(resolve => setTimeout(resolve, 2500));
}
} catch (nextError) {
shouldContinue = false;
}
}
}
return {
success: true,
message: `Bulk processing completed. Processed ${pageCount} pages (${successCount} success, ${errorCount} errors)`,
pageCount,
successCount,
errorCount
};
} catch (error) {
return {
success: false,
message: 'Bulk processing failed: ' + error.message,
pageCount,
successCount,
errorCount
};
}
}
// Listen for messages from popup
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === 'executePrivacyUpdate') {
executePrivacyUpdate().then(result => {
sendResponse(result);
});
return true; // Keep message channel open for async response
} else if (request.action === 'executeBulkPrivacyUpdate') {
executeBulkPrivacyUpdate().then(result => {
sendResponse(result);
});
return true; // Keep message channel open for async response
}
});
// Make functions available globally for script injection
window.executePrivacyUpdate = executePrivacyUpdate;
window.executeBulkPrivacyUpdate = executeBulkPrivacyUpdate;
window.findAndClickNext = findAndClickNext;
console.log('FetLife Privacy Helper content script loaded with XHR support');
})();
\ No newline at end of file
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<rect width="128" height="128" rx="20" fill="#4CAF50"/>
<circle cx="64" cy="64" r="40" fill="white" opacity="0.9"/>
<path d="M50 64 L58 72 L78 52" stroke="#4CAF50" stroke-width="4" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
<text x="64" y="100" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-size="12" font-weight="bold">PRIVAXY</text>
</svg>
\ No newline at end of file
icon128.png

4.61 KB

icon48.png

3.83 KB

{
"manifest_version": 3,
"name": "FetLife Privacy Helper",
"version": "1.0",
"description": "Automates privacy settings on FetLife - by sexhack.me",
"author": "Stefy Spora <stefy@sexhack.me>",
"permissions": [
"activeTab",
"contextMenus",
"scripting",
"notifications"
],
"host_permissions": [
"https://fetlife.com/*"
],
"action": {
"default_popup": "popup.html",
"default_title": "FetLife Privacy Helper"
},
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["https://fetlife.com/*"],
"js": ["content.js"]
}
],
"icons": {
"16": "icon16.png",
"48": "icon48.png",
"128": "icon128.png"
}
}
\ No newline at end of file
<!--
FetLife Privacy Helper - Popup Interface
Copyright (C) 2025 Stefy Spora <stefy@sexhack.me> - sexhack.me
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {
width: 200px;
padding: 10px;
font-family: Arial, sans-serif;
}
.header {
text-align: center;
margin-bottom: 15px;
color: #333;
font-size: 14px;
font-weight: bold;
}
.privaxy-btn {
width: 100%;
padding: 10px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
}
.privaxy-btn:hover {
background-color: #45a049;
}
.privaxy-btn:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
.status {
margin-top: 10px;
padding: 5px;
text-align: center;
font-size: 12px;
border-radius: 3px;
}
.status.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.status.info {
background-color: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
</style>
</head>
<body>
<div class="header">FetLife Privacy Helper</div>
<button id="privaxy-btn" class="privaxy-btn">Privaxy</button>
<button id="run-all-btn" class="privaxy-btn" style="background-color: #2196F3; margin-top: 5px;">Run on All Media</button>
<button id="stop-btn" class="privaxy-btn" style="background-color: #f44336; margin-top: 5px; display: none;">Stop</button>
<div id="progress" class="status" style="display: none; background-color: #fff3cd; color: #856404; border: 1px solid #ffeaa7;"></div>
<div id="status" class="status" style="display: none;"></div>
<div class="footer" style="margin-top: 15px; padding-top: 10px; border-top: 1px solid #ddd; font-size: 11px; text-align: center; color: #666;">
<div style="margin-bottom: 5px;">
<a href="https://www.sexhack.me" target="_blank" style="color: #4CAF50; text-decoration: none;">sexhack.me</a>
<span style="color: #f44336; font-weight: bold;">(NSFW)</span>
</div>
<div style="margin-bottom: 5px;">
<a href="https://git.nexlab.net/sexhackme/fetprivacy" target="_blank" style="color: #2196F3; text-decoration: none;">Source Code</a>
</div>
<div style="font-size: 10px;">
<strong>₿ Donate:</strong><br>
<span style="font-family: monospace; font-size: 9px; word-break: break-all;">bc1qcpt2uutqkz4456j5r78rjm3gwq03h5fpwmcc5u</span>
</div>
</div>
<script src="popup.js"></script>
</body>
</html>
\ No newline at end of file
/*
* FetLife Privacy Helper - Popup Script
* Copyright (C) 2025 Stefy Spora <stefy@sexhack.me> - sexhack.me
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
document.addEventListener('DOMContentLoaded', function() {
const privacyBtn = document.getElementById('privaxy-btn');
const runAllBtn = document.getElementById('run-all-btn');
const stopBtn = document.getElementById('stop-btn');
const statusDiv = document.getElementById('status');
const progressDiv = document.getElementById('progress');
let isRunningBulk = false;
let shouldStop = false;
function showStatus(message, type = 'info') {
statusDiv.textContent = message;
statusDiv.className = `status ${type}`;
statusDiv.style.display = 'block';
// Hide status after 3 seconds unless it's an error or we're running bulk
if (type !== 'error' && !isRunningBulk) {
setTimeout(() => {
statusDiv.style.display = 'none';
}, 3000);
}
}
function showProgress(message) {
progressDiv.textContent = message;
progressDiv.style.display = 'block';
}
function hideProgress() {
progressDiv.style.display = 'none';
}
function disableButton() {
privacyBtn.disabled = true;
privacyBtn.textContent = 'Processing...';
}
function enableButton() {
privacyBtn.disabled = false;
privacyBtn.textContent = 'Privaxy';
}
function disableBulkButtons() {
runAllBtn.disabled = true;
runAllBtn.textContent = 'Running...';
privacyBtn.disabled = true;
stopBtn.style.display = 'block';
stopBtn.disabled = false;
}
function enableBulkButtons() {
runAllBtn.disabled = false;
runAllBtn.textContent = 'Run on All Media';
privacyBtn.disabled = false;
stopBtn.style.display = 'none';
isRunningBulk = false;
shouldStop = false;
}
privacyBtn.addEventListener('click', async function() {
try {
disableButton();
showStatus('Checking current tab...', 'info');
// Get the current active tab
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab.url.includes('fetlife.com')) {
showStatus('Please navigate to FetLife first', 'error');
enableButton();
return;
}
showStatus('Executing privacy update...', 'info');
// Execute the content script function
const results = await chrome.scripting.executeScript({
target: { tabId: tab.id },
function: executePrivacyUpdate
});
const result = results[0].result;
if (result.success) {
showStatus(result.message, 'success');
} else {
showStatus(result.message, 'error');
}
} catch (error) {
console.error('Error:', error);
showStatus('An error occurred: ' + error.message, 'error');
} finally {
enableButton();
}
});
runAllBtn.addEventListener('click', async function() {
try {
isRunningBulk = true;
shouldStop = false;
disableBulkButtons();
showStatus('Starting bulk privacy update...', 'info');
showProgress('Initializing...');
// Get the current active tab
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab.url.includes('fetlife.com')) {
showStatus('Please navigate to FetLife first', 'error');
enableBulkButtons();
hideProgress();
return;
}
let pageCount = 0;
let successCount = 0;
let errorCount = 0;
// Start the bulk processing loop
while (!shouldStop) {
pageCount++;
showProgress(`Processing page ${pageCount}... (${successCount} success, ${errorCount} errors)`);
try {
// Execute privacy update on current page
const results = await chrome.scripting.executeScript({
target: { tabId: tab.id },
function: executePrivacyUpdate
});
const result = results[0].result;
if (result.success) {
successCount++;
} else {
errorCount++;
console.log(`Page ${pageCount} error:`, result.message);
}
// Wait a moment before checking for next link
await new Promise(resolve => setTimeout(resolve, 1000));
if (shouldStop) break;
// Check for and click next link
const nextResults = await chrome.scripting.executeScript({
target: { tabId: tab.id },
function: findAndClickNext
});
const nextResult = nextResults[0].result;
if (!nextResult.hasNext) {
showStatus(`Completed! Processed ${pageCount} pages (${successCount} success, ${errorCount} errors)`, 'success');
break;
}
// Wait for page to load
await new Promise(resolve => setTimeout(resolve, 2000));
} catch (error) {
errorCount++;
console.error(`Error on page ${pageCount}:`, error);
// Try to continue to next page even if current page failed
try {
const nextResults = await chrome.scripting.executeScript({
target: { tabId: tab.id },
function: findAndClickNext
});
if (!nextResults[0].result.hasNext) {
break;
}
await new Promise(resolve => setTimeout(resolve, 2000));
} catch (nextError) {
console.error('Failed to navigate to next page:', nextError);
break;
}
}
}
if (shouldStop) {
showStatus(`Stopped by user. Processed ${pageCount} pages (${successCount} success, ${errorCount} errors)`, 'info');
}
} catch (error) {
console.error('Bulk processing error:', error);
showStatus('Bulk processing failed: ' + error.message, 'error');
} finally {
enableBulkButtons();
hideProgress();
}
});
stopBtn.addEventListener('click', function() {
shouldStop = true;
stopBtn.disabled = true;
stopBtn.textContent = 'Stopping...';
showProgress('Stopping after current page...');
});
});
// Function to find and click the next link
function findAndClickNext() {
try {
// Look for the "next »" link
const nextLinks = Array.from(document.querySelectorAll('a')).filter(link =>
link.textContent.trim().includes('next »') ||
link.textContent.trim().includes('next ›') ||
link.textContent.trim() === 'next'
);
if (nextLinks.length === 0) {
return { hasNext: false, message: 'No next link found' };
}
// Click the first next link found
nextLinks[0].click();
return { hasNext: true, message: 'Clicked next link' };
} catch (error) {
return { hasNext: false, message: 'Error finding next link: ' + error.message };
}
}
// This function will be injected into the page - XHR-based approach
function executePrivacyUpdate() {
// Function to extract media ID and type from URL
function getMediaInfo() {
const url = window.location.href;
// Handle both direct URLs and username-based URLs
// Direct: https://fetlife.com/videos/123 or https://fetlife.com/pictures/123
// Username: https://fetlife.com/username/videos/123 or https://fetlife.com/username/pictures/123
const videoMatch = url.match(/https:\/\/fetlife\.com\/(?:[^\/]+\/)?videos\/(\d+)/);
const pictureMatch = url.match(/https:\/\/fetlife\.com\/(?:[^\/]+\/)?pictures\/(\d+)/);
if (videoMatch) {
return { type: 'video', id: videoMatch[1] };
} else if (pictureMatch) {
return { type: 'picture', id: pictureMatch[1] };
}
return null;
}
// Function to get CSRF token from the page
function getCSRFToken() {
const metaTag = document.querySelector('meta[name="csrf-token"]');
if (metaTag) {
return metaTag.getAttribute('content');
}
const csrfInput = document.querySelector('input[name="authenticity_token"]');
if (csrfInput) {
return csrfInput.value;
}
return null;
}
// Main XHR-based privacy update
return new Promise(async (resolve) => {
try {
console.log('Starting XHR-based privacy update...');
// Step 1: Detect media type and ID from URL
const mediaInfo = getMediaInfo();
if (!mediaInfo) {
resolve({ success: false, message: 'Not on a video or picture page. Please navigate to a FetLife video or picture URL.' });
return;
}
console.log(`Detected ${mediaInfo.type} with ID: ${mediaInfo.id}`);
// Step 2: Get CSRF token
const csrfToken = getCSRFToken();
if (!csrfToken) {
resolve({ success: false, message: 'Could not find CSRF token. Please refresh the page and try again.' });
return;
}
// Step 3: Prepare payload based on media type
let payload;
if (mediaInfo.type === 'video') {
payload = {
video: {
title: "",
description: "",
only_friends: true,
tag_names: [],
user_tag_ids: []
},
render_flash: true
};
} else {
payload = {
picture: {
caption: "",
content_privacy: "only_friends",
tag_names: [],
user_tag_ids: []
},
render_flash: true
};
}
console.log('Sending XHR request with payload:', payload);
// Step 4: Send XHR PUT request
const response = await fetch(`https://fetlife.com/${mediaInfo.type}s/${mediaInfo.id}`, {
method: 'PUT',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json, text/javascript, */*; q=0.01',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify(payload)
});
if (!response.ok) {
resolve({ success: false, message: `XHR request failed: ${response.status} ${response.statusText}` });
return;
}
const result = await response.json();
console.log('XHR response:', result);
resolve({
success: true,
message: `${mediaInfo.type.charAt(0).toUpperCase() + mediaInfo.type.slice(1)} privacy updated to "only friends" successfully!`
});
} catch (error) {
console.error('XHR privacy update failed:', error);
resolve({ success: false, message: error.message });
}
});
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment