mirror of
https://github.com/jason5ng32/MyIP.git
synced 2026-11-04 08:55:47 +00:00
Feat(github-stars): self-host the repo star count, drop shields.io
shields.io's stars badge kept failing on GitHub token-pool exhaustion
("Unable to select the next GitHub token from pool"). Serve the count
from our own backend instead and render the badge ourselves.
- api/github-stars.js: fetch stargazers_count unauthenticated (no token),
edge-cached for a day in backend-server.js. Behind Cloudflare the origin
hits GitHub at most once per cache window, well under the 60 req/hour
unauthenticated limit; error responses stay no-store so failures aren't
cached.
- Nav.vue: replace the shields.io <img> with our own GitHub badge on
desktop and add the count to the mobile drawer's "Star on GitHub" row,
both fed by /api/github-stars (fetched on mount). Count is hidden until
it lands / on error, so the link never depends on the fetch.
- format-star-count.js: pure compact formatter (1234 -> 1.2k) with tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
976e971371
commit
83752e2089
7 files changed
+152
-11
No files matched your search
@@ -22,6 +22,8 @@ api/
|
||||
│ ipcheck-ing.js, maxmind.js ← IP geolocation source handlers (route per source)
|
||||
├── invisibility-test.js ← /api/invisibility — proxy to private IPCheck.ing endpoint
|
||||
├── mac-checker.js ← /api/macchecker — MAC vendor lookup
|
||||
├── github-stars.js ← /api/github-stars — repo star count via no-token
|
||||
│ GitHub fetch (edge-cached; replaces shields.io)
|
||||
├── get-whois.js ← /api/whois — whoiser primary + RDAP fallback for new gTLDs
|
||||
├── cf-radar.js ← /api/cfradar — ASN details via Cloudflare Radar
|
||||
├── dns-resolver.js ← /api/dnsresolver — DNS + DoH parallel query
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// /api/github-stars — stargazer count for this project's repo.
|
||||
//
|
||||
// Fetched from GitHub's public REST API without a token: `stargazers_count` is
|
||||
// available unauthenticated, which sidesteps shields.io's token-pool outages
|
||||
// ("Unable to select the next GitHub token from pool"). The route is edge-cached
|
||||
// for a day (see backend-server.js), so behind Cloudflare the origin queries
|
||||
// GitHub at most once per cache window — far under the 60 req/hour
|
||||
// unauthenticated limit — and every other request is served from the CF edge.
|
||||
import { fetchUpstream } from '../common/fetch-with-timeout.js';
|
||||
import logger from '../common/logger.js';
|
||||
|
||||
// The project's own repository. GitHub requires a User-Agent on every request.
|
||||
const REPO = 'jason5ng32/MyIP';
|
||||
|
||||
export default async (req, res) => {
|
||||
// Defensive; app.get() in backend-server.js already gates method, but a
|
||||
// dedicated smoke test asserts this branch directly against the handler.
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ message: 'Method Not Allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const apiRes = await fetchUpstream(`https://api.github.com/repos/${REPO}`, {
|
||||
headers: {
|
||||
'Accept': 'application/vnd.github+json',
|
||||
'User-Agent': 'MyIP-IPCheck.ing',
|
||||
},
|
||||
});
|
||||
if (!apiRes.ok) {
|
||||
throw new Error(`GitHub API responded ${apiRes.status}`);
|
||||
}
|
||||
const data = await apiRes.json();
|
||||
res.json({ stars: data.stargazers_count ?? 0 });
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'github-stars handler failed');
|
||||
res.status(500).json({ error: 'Failed to fetch GitHub stars' });
|
||||
}
|
||||
};
|
||||
@@ -31,6 +31,7 @@ import { getSessionResult as dnsLeakGetResult } from './api/dns-leak-test.js';
|
||||
import getWhois from './api/get-whois.js';
|
||||
import invisibilitytestHandler from './api/invisibility-test.js';
|
||||
import macChecker from './api/mac-checker.js';
|
||||
import githubStarsHandler from './api/github-stars.js';
|
||||
// User
|
||||
import validateConfigs from './api/configs.js';
|
||||
import getUserinfo from './api/get-user-info.js';
|
||||
@@ -210,6 +211,7 @@ app.get('/api/ipapiis', requireValidIP(), cacheable(ONE_HOUR_CACHE), ipapiisHand
|
||||
app.get('/api/ip2location', requireValidIP(), cacheable(ONE_HOUR_CACHE), ip2locationHandler);
|
||||
app.get('/api/macchecker', cacheable(THIRTY_DAYS_CACHE), macChecker);
|
||||
app.get('/api/maxmind', requireValidIP(), cacheable(ONE_DAY_CACHE), maxmindHandler);
|
||||
app.get('/api/github-stars', cacheable(ONE_DAY_CACHE), githubStarsHandler);
|
||||
app.get('/api/service-status', cacheable(FIVE_MIN_CACHE), serviceStatusHandler);
|
||||
app.get('/api/service-status/detail', requireValidProviderId(), cacheable(FIVE_MIN_CACHE), serviceStatusDetailHandler);
|
||||
|
||||
|
||||
+35
-11
@@ -63,21 +63,22 @@
|
||||
{{ t(`nav.${item}`) }}
|
||||
</a>
|
||||
</template>
|
||||
<!-- GitHub repo link + star count. Count comes from our own backend
|
||||
(/api/github-stars, edge-cached) instead of shields.io, which kept
|
||||
failing on token-pool exhaustion. The count is hidden until it
|
||||
lands / on error, so the link itself never depends on the fetch. -->
|
||||
<Badge variant="outline" v-if="githubStarsLabel">
|
||||
<a :href="t('page.footerLink')" target="_blank" rel="noopener"
|
||||
class="ml-2 inline-flex items-center hover:opacity-80 transition-opacity" aria-label="View source on GitHub">
|
||||
<img src="https://img.shields.io/github/stars/jason5ng32/MyIP" alt="GitHub stars" class="h-5">
|
||||
class="inline-flex items-center gap-1"
|
||||
aria-label="View source on GitHub">
|
||||
<Icon icon="ri:github-line" class="size-3.5" />
|
||||
<span class="tabular-nums">{{ githubStarsLabel }}</span>
|
||||
</a>
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<!-- Right: Action area (ml-auto push to the right) -->
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<!-- Mobile: GitHub icon -->
|
||||
<Button v-if="isMobile" variant="ghost" size="icon" class="size-8" as-child>
|
||||
<a :href="t('page.footerLink')" target="_blank" rel="noopener" aria-label="View source on GitHub">
|
||||
<Icon icon="ri:github-line" />
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
<!-- Preferences -->
|
||||
<JnTooltip :text="t('shortcutKeys.Preferences')">
|
||||
<Button variant="ghost" size="icon" class="size-8 cursor-pointer" aria-label="Open preferences" @click="OpenPreferences">
|
||||
@@ -213,9 +214,14 @@
|
||||
</a>
|
||||
</template>
|
||||
<a :href="t('page.footerLink')" target="_blank" rel="noopener"
|
||||
class="mt-3 inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm font-medium hover:bg-muted">
|
||||
class="mt-3 flex items-center gap-2 rounded-md border px-3 py-2 text-sm font-medium hover:bg-muted">
|
||||
<Icon icon="ri:github-line" class="size-4" />
|
||||
<span>Star on GitHub</span>
|
||||
<!-- Same /api/github-stars count as the desktop badge (fetched on
|
||||
mount); hidden until it lands / on error. -->
|
||||
<span v-if="githubStarsLabel" class="ml-auto tabular-nums text-muted-foreground">
|
||||
{{ githubStarsLabel }}
|
||||
</span>
|
||||
</a>
|
||||
</nav>
|
||||
</SheetContent>
|
||||
@@ -252,12 +258,14 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Award, ChevronDown, HeartHandshake,
|
||||
LogOut, Menu,Cog
|
||||
LogOut, Menu, Cog,
|
||||
} from '@lucide/vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import brandIcon from './svgicons/Brand.vue';
|
||||
import { SECTION_IDS } from '@/data/sections';
|
||||
import { ADVANCED_TOOLS } from '@/data/tools.js';
|
||||
import { fetchWithTimeout } from '@/utils/fetch-with-timeout.js';
|
||||
import { formatStarCount } from '@/utils/format-star-count.js';
|
||||
import { isRunningAsPwa } from '@/utils/pwa.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -285,6 +293,21 @@ const advancedTools = computed(() =>
|
||||
// Mobile: Advanced Tools sub-list expanded by default for discoverability.
|
||||
const mobileToolsOpen = ref(true);
|
||||
|
||||
// GitHub star count for the repo badge. Fetched from our own edge-cached
|
||||
// endpoint; stays null (badge hides the count) if the request fails.
|
||||
const githubStars = ref(null);
|
||||
const githubStarsLabel = computed(() => formatStarCount(githubStars.value));
|
||||
const fetchGithubStars = async () => {
|
||||
try {
|
||||
const res = await fetchWithTimeout('/api/github-stars');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (typeof data.stars === 'number') githubStars.value = data.stars;
|
||||
} catch {
|
||||
/* leave the badge without a count */
|
||||
}
|
||||
};
|
||||
|
||||
// nav link style — current section highlight use bg-accent instead of only bold
|
||||
const navLinkClass = (item, { block = false } = {}) => {
|
||||
const base = 'rounded-md px-3 py-1.5 text-sm font-medium no-underline cursor-pointer transition-colors';
|
||||
@@ -418,6 +441,7 @@ onMounted(() => {
|
||||
lastScrollY = window.scrollY;
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
}
|
||||
fetchGithubStars();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Compact formatter for the GitHub star count shown in the nav badge.
|
||||
// Mirrors the shields.io style we replaced: < 1000 stays exact, larger values
|
||||
// collapse to a one-decimal "k" / "M" (trailing ".0" dropped). Returns '' for
|
||||
// anything that isn't a usable non-negative number, so the caller can simply
|
||||
// hide the count when the fetch fails or hasn't landed yet.
|
||||
|
||||
const UNITS = [
|
||||
{ value: 1e6, suffix: 'M' },
|
||||
{ value: 1e3, suffix: 'k' },
|
||||
];
|
||||
|
||||
export function formatStarCount(value) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return '';
|
||||
if (value < 1000) return String(Math.floor(value));
|
||||
|
||||
for (const { value: unit, suffix } of UNITS) {
|
||||
if (value >= unit) {
|
||||
const scaled = value / unit;
|
||||
// Three-or-more-digit results (e.g. 123k) read better without a decimal.
|
||||
const str = scaled >= 100
|
||||
? String(Math.round(scaled))
|
||||
: scaled.toFixed(1).replace(/\.0$/, '');
|
||||
return str + suffix;
|
||||
}
|
||||
}
|
||||
return String(Math.floor(value));
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import getWhoisHandler from '../api/get-whois.js';
|
||||
import cfRadarHandler from '../api/cf-radar.js';
|
||||
import invisibilityHandler from '../api/invisibility-test.js';
|
||||
import macCheckerHandler from '../api/mac-checker.js';
|
||||
import githubStarsHandler from '../api/github-stars.js';
|
||||
import updateAchievementHandler from '../api/update-user-achievement.js';
|
||||
import ipcheckIngHandler from '../api/ipcheck-ing.js';
|
||||
import { getSessionResult as dnsLeakGetResult } from '../api/dns-leak-test.js';
|
||||
@@ -153,6 +154,17 @@ describe('get-whois handler', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -- github-stars handler -------------------------------------------------
|
||||
|
||||
describe('github-stars handler', () => {
|
||||
it('rejects non-GET with 405 before hitting GitHub', async () => {
|
||||
const res = createResponse();
|
||||
await githubStarsHandler(createRequest({ method: 'POST' }), res);
|
||||
assert.equal(res.statusCode, 405);
|
||||
assert.deepEqual(res.body, { message: 'Method Not Allowed' });
|
||||
});
|
||||
});
|
||||
|
||||
// -- cf-radar handler -----------------------------------------------------
|
||||
|
||||
describe('cf-radar handler', () => {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Unit tests for the GitHub star-count compact formatter.
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { formatStarCount } from '../frontend/utils/format-star-count.js';
|
||||
|
||||
describe('formatStarCount', () => {
|
||||
it('returns exact integers below 1000', () => {
|
||||
assert.equal(formatStarCount(0), '0');
|
||||
assert.equal(formatStarCount(7), '7');
|
||||
assert.equal(formatStarCount(999), '999');
|
||||
});
|
||||
|
||||
it('collapses thousands to one-decimal k, dropping trailing .0', () => {
|
||||
assert.equal(formatStarCount(1000), '1k');
|
||||
assert.equal(formatStarCount(1234), '1.2k');
|
||||
assert.equal(formatStarCount(2000), '2k');
|
||||
assert.equal(formatStarCount(12345), '12.3k');
|
||||
});
|
||||
|
||||
it('drops the decimal once the k value reaches three digits', () => {
|
||||
assert.equal(formatStarCount(123456), '123k');
|
||||
});
|
||||
|
||||
it('collapses millions to M', () => {
|
||||
assert.equal(formatStarCount(1234567), '1.2M');
|
||||
});
|
||||
|
||||
it('returns empty string for unusable input', () => {
|
||||
assert.equal(formatStarCount(null), '');
|
||||
assert.equal(formatStarCount(undefined), '');
|
||||
assert.equal(formatStarCount(NaN), '');
|
||||
assert.equal(formatStarCount(-5), '');
|
||||
assert.equal(formatStarCount('1234'), '');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user