initial commit

This commit is contained in:
red binder 2026-08-09 00:22:35 +02:00
commit 0144534910
5 changed files with 1333 additions and 0 deletions

3
.env.example Normal file
View file

@ -0,0 +1,3 @@
PORT=
DATABASE_URL=
SERVER_STATIC_TOKEN=

143
.gitignore vendored Normal file
View file

@ -0,0 +1,143 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.*
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
.output
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp directory
.temp
# Sveltekit cache directory
.svelte-kit/
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Firebase cache directory
.firebase/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# pnpm
.pnpm-store
# yarn v3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Vite files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/

153
index.js Normal file
View file

@ -0,0 +1,153 @@
import express from 'express';
import { Pool } from 'pg';
import axios from 'axios';
import 'dotenv/config';
const app = express();
app.use(express.json());
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
connectionTimeoutMillis: 5000
});
pool.on('error', (err) => {
console.error('pool error: ', err);
});
const SERVER_STATIC_TOKEN = process.env.SERVER_STATIC_TOKEN;
async function authenticateUser(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing or malformed Authorization header' });
}
const token = authHeader.split(' ')[1];
try {
const startAuthTime = Date.now();
const response = await axios.get('https://account.spbr.net/api/v2/users/@me/profile', {
headers: { 'Authorization': `Bearer ${token}` },
timeout: 5000
});
if (!response.data || !response.data.pid) {
return res.status(401).json({ error: 'Invalid user token response' });
}
req.user = response.data;
next();
} catch (err) {
return res.status(401).json({ error: 'Authentication failed', details: err.message });
}
}
function authenticateServer(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing or malformed Authorization header' });
}
const token = authHeader.split(' ')[1];
if (token !== SERVER_STATIC_TOKEN) {
return res.status(403).json({ error: 'Forbidden: Invalid server token' });
}
next();
}
app.get('/api/v1/@me', authenticateUser, async (req, res) => {
const userPid = req.user.pid;
try {
const queryText = `
SELECT
f.pid AS friend_pid,
f.since AS friended_since,
p.presence,
p.updated_at
FROM friendships_of_pid($1) f
LEFT JOIN user_presences p ON f.pid = p.pid;
`;
const queryStartTime = Date.now();
const { rows } = await pool.query(queryText, [userPid]);
const responseData = {
user: req.user,
friends: rows.map(row => ({
pid: row.friend_pid,
since: row.friended_since,
presence: row.presence || null,
last_updated: row.updated_at || null
}))
};
return res.status(200).json(responseData);
} catch (err) {
return res.status(500).json({ code: "0402", error: 'Internal server error' });
}
});
app.get('/api/v1/presence/:pid', authenticateUser, async (req, res) => {
const targetPid = parseInt(req.params.pid, 10);
const userPid = req.user.pid;
if (isNaN(targetPid)) {
return res.status(400).json({ error: 'Invalid target PID' });
}
try {
const friendshipCheck = `
SELECT EXISTS (
SELECT 1 FROM friendships_of_pid($1) WHERE pid = $2
) AS is_friend;
`;
const checkResult = await pool.query(friendshipCheck, [userPid, targetPid]);
if (!checkResult.rows[0].is_friend) {
return res.status(403).json({ error: 'Request denied: target user is not in your friends list' });
}
const presenceQuery = `SELECT presence, updated_at FROM user_presences WHERE pid = $1;`;
const presenceResult = await pool.query(presenceQuery, [targetPid]);
if (presenceResult.rows.length === 0) {
return res.status(204).json({ error: 'Presence not found for target PID' });
}
return res.status(200).json({
pid: targetPid,
presence: presenceResult.rows[0].presence,
last_updated: presenceResult.rows[0].updated_at
});
} catch (err) {
return res.status(500).json({ error: 'Internal server error' });
}
});
app.post('/api/v1/presence', authenticateServer, async (req, res) => {
const { pid, presence } = req.body;
if (!pid || !presence) {
return res.status(400).json({ error: 'Missing pid or presence body' });
}
try {
const queryText = `
INSERT INTO user_presences (pid, presence, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (pid)
DO UPDATE SET presence = EXCLUDED.presence, updated_at = NOW();
`;
await pool.query(queryText, [pid, JSON.stringify(presence)]);
return res.status(200).json({ status: 'success' });
} catch (err) {
return res.status(500).json({ error: 'Internal server error' });
}
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`running on port ${PORT}`);
});

26
package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "friends-api",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "^11.3.0",
"onFail": "download"
}
},
"type": "module",
"dependencies": {
"axios": "^1.19.0",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"pg": "^8.22.0"
}
}

1008
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff