Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
node_modules/
settings.json
settings-*.json
!settings-example.json
1 change: 1 addition & 0 deletions .meteor/packages
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ hot-module-replacement@0.5.4 # Update code in development without reloading the
blaze-hot # Update files using Blaze's API with HMR
accounts-base
accounts-password
http
84 changes: 84 additions & 0 deletions URL_TITLE_PROXY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# URL Title Proxy Configuration

The URL title autofill feature can be configured through Meteor settings. Create a `settings.json` file and pass it to Meteor using `--settings settings.json`.

## Configuration Options

```json
{
"urlTitleProxy": {
"enabled": true,
"timeout": 10,
"maxResponseSize": 1048576,
"maxTitleLength": 200,
"requireAuth": true,
"allowedDomains": [],
"blockPrivateNetworks": true
}
}
```

### Settings Description

- **enabled** (boolean, default: true): Enable or disable the URL title proxy service
- **timeout** (number, default: 10): Maximum timeout for HTTP requests in seconds
- **maxResponseSize** (number, default: 1048576): Maximum response size in bytes (1MB)
- **maxTitleLength** (number, default: 200): Maximum length of extracted titles
- **requireAuth** (boolean, default: true): Require user authentication to use the service
- **allowedDomains** (array, default: []): List of allowed domains. Empty array allows all external domains
- **blockPrivateNetworks** (boolean, default: true): Block access to private/internal IP ranges

## Security Features

### SSRF Protection
- Blocks localhost and loopback addresses (`127.0.0.1`, `::1`, `localhost`)
- Prevents access to private IP ranges (`10.x`, `192.168.x`, `172.16-31.x`)
- Blocks local domains (`.local`, `.localhost`)
- Only allows HTTP/HTTPS protocols

### Request Safety
- Configurable timeout prevents hanging requests
- Configurable response size limit prevents memory exhaustion
- Configurable title length limit for reasonable display
- Proper User-Agent identification
- Authentication requirement by default

### Domain Restrictions
- Optional domain allowlist for additional security
- Configurable private network blocking

## Example Configurations

### Development (Permissive)
```json
{
"urlTitleProxy": {
"enabled": true,
"requireAuth": false,
"allowedDomains": [],
"blockPrivateNetworks": true
}
}
```

### Production (Restrictive)
```json
{
"urlTitleProxy": {
"enabled": true,
"requireAuth": true,
"allowedDomains": ["github.com", "stackoverflow.com", "docs.company.com"],
"blockPrivateNetworks": true,
"timeout": 5
}
}
```

### Disabled
```json
{
"urlTitleProxy": {
"enabled": false
}
}
```
6 changes: 5 additions & 1 deletion client/main.html
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,11 @@ <h3 class="text-xl font-semibold mb-4">My Activities & Tasks</h3>
</div>
{{#if showCreateTicketForm}}
<form id="createTicketForm" class="card bg-base-100 shadow p-4 mb-4 flex flex-col gap-2">
<input type="text" name="title" placeholder="Activity Title (e.g., 'Math Homework', 'Robot Assembly')" class="input input-bordered" required />
<input type="text" id="activityTitle" name="title" placeholder="Activity Title (e.g., 'Math Homework', 'Robot Assembly')" class="input input-bordered" required />
<div id="titleFetchStatus" class="text-sm mt-1 hidden">
<span class="loading loading-spinner loading-xs"></span>
<span class="ml-2">Fetching page title...</span>
</div>
<input type="text" name="github" placeholder="Reference Link or Notes" class="input input-bordered" />
<div class="flex gap-2">
<input type="number" name="hours" min="0" placeholder="Hours" class="input input-bordered w-24" />
Expand Down
102 changes: 102 additions & 0 deletions client/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,51 @@ const currentScreen = new ReactiveVar('authPage');
const currentTime = new ReactiveVar(Date.now());
setInterval(() => currentTime.set(Date.now()), 1000);

// URL detection and title fetching utilities
let titleFetchTimeout = null;

function isValidUrl(string) {
try {
const url = new URL(string);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch (_) {
return false;
}
}

function showTitleFetchStatus(show = true) {
const statusEl = document.getElementById('titleFetchStatus');
if (statusEl) {
if (show) {
statusEl.classList.remove('hidden');
} else {
statusEl.classList.add('hidden');
}
}
}

function fetchAndSetTitle(url, inputElement) {
showTitleFetchStatus(true);

Meteor.call('fetchUrlTitle', url, (error, title) => {
showTitleFetchStatus(false);

if (error) {
console.warn('Failed to fetch URL title:', error.reason);
return;
}

if (title && title.trim()) {
// Only set title if the input is still focused on the URL or is empty/unchanged
const currentValue = inputElement.value.trim();
if (currentValue === url || currentValue === '') {
inputElement.value = title;
inputElement.dispatchEvent(new Event('input', { bubbles: true }));
}
}
});
}

Template.mainLayout.onCreated(function () {
this.autorun(() => {
if (Meteor.userId()) {
Expand Down Expand Up @@ -347,10 +392,67 @@ Template.tickets.events({
t.showCreateTicketForm.set(true);
},
'click #cancelCreateTicket'(e, t) {
// Clear any pending title fetch timeout
if (titleFetchTimeout) {
clearTimeout(titleFetchTimeout);
titleFetchTimeout = null;
}
showTitleFetchStatus(false);
t.showCreateTicketForm.set(false);
},
'paste #activityTitle'(e) {
// Handle paste events for URL detection
setTimeout(() => {
const input = e.target;
const value = input.value.trim();

if (isValidUrl(value)) {
// Clear any existing timeout
if (titleFetchTimeout) {
clearTimeout(titleFetchTimeout);
}

// Debounce the request by 300ms
titleFetchTimeout = setTimeout(() => {
fetchAndSetTitle(value, input);
}, 300);
}
}, 10); // Small delay to ensure paste content is available
},
'input #activityTitle'(e) {
// Handle typing for URL detection
const input = e.target;
const value = input.value.trim();

// Clear any existing timeout
if (titleFetchTimeout) {
clearTimeout(titleFetchTimeout);
}

// Hide status if no longer a URL
if (!isValidUrl(value)) {
showTitleFetchStatus(false);
return;
}

// Only trigger for complete URLs (basic heuristic)
if (value.length > 10 && (value.includes('.com') || value.includes('.org') || value.includes('.net') || value.includes('.edu') || value.includes('.gov'))) {
// Debounce the request by 300ms
titleFetchTimeout = setTimeout(() => {
fetchAndSetTitle(value, input);
}, 300);
}
},
'submit #createTicketForm'(e, t) {
e.preventDefault();

// Clear any pending title fetch timeout
if (titleFetchTimeout) {
clearTimeout(titleFetchTimeout);
titleFetchTimeout = null;
}
showTitleFetchStatus(false);

const teamId = t.selectedTeamId.get();
const title = e.target.title.value.trim();
const github = e.target.github.value.trim();
Expand Down
Loading