diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2d84af7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +# QRknit .dockerignore +__pycache__/ +*.pyc +*.pyo +*.pyd +*.db +*.sqlite +.env +.env.* +*.log +.git/ +.gitignore +*.md +setup.sh +docker-compose.yml +.DS_Store +venv/ +.venv/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..01f42da --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +*.pyo +*.db +.env diff --git a/api-docs.html b/api-docs.html new file mode 100644 index 0000000..908851f --- /dev/null +++ b/api-docs.html @@ -0,0 +1,723 @@ + + + +
+ + ++ Complete reference for the QRknit REST API. Manage links, generate QR codes, pull analytics, and administer users — all from your own code. +
+
+ All write endpoints require an active session. Log in via the web UI or call POST /api/auth/login with
+ {"username": "…", "password": "…"} — the server sets an HttpOnly session cookie valid for 30 days.
+ Include that cookie in all subsequent requests. Endpoints marked Admin additionally require
+ the authenticated user to have admin privileges.
+
Session-based login. A successful login sets a 30-day HttpOnly cookie used for all authenticated requests.
+| Method | +Endpoint | +Auth | +Description | +
|---|---|---|---|
| POST | +/api/auth/login | +— | +Login — body: {"username": "…", "password": "…"}. Sets session cookie and returns plan, plan_limits, and org_id. |
+
| POST | +/api/auth/logout | +Session | +Clear the current session and invalidate the cookie. | +
| GET | +/api/auth/me | +Session | +Returns {"authenticated": true, "username": "…", "is_admin": bool, "org_id": int|null, "plan": "…", "plan_limits": {…}}. |
+
Create, read, update, and delete short links. Org members see all links within their org. Admin accounts see only their own links by default, with optional ?user= filtering.
| Method | +Endpoint | +Auth | +Description | +
|---|---|---|---|
| POST | +/api/shorten | +Session | +Create a short link. Body: {"url": "…", "code": "…", "title": "…", "expires_at": "…", "tags": […], "is_pinned": bool}. code is optional — omit for a random code. |
+
| GET | +/api/links | +Session | +List links. Query params: ?q= (search), ?tag=, ?page=, ?per_page=. Admin can add ?user=<username> to filter by user. |
+
| GET | +/api/links/:code | +Session | +Link detail — includes created_by username. |
+
| PATCH | +/api/links/:code | +Session | +Edit link — updatable fields: url, title, expires_at, tags, is_pinned. |
+
| DELETE | +/api/links/:code | +Session | +Delete a link. | +
| GET | +/api/links/:code/analytics | +Session | +Click analytics. Query: ?days=7|30|90 (capped to plan's max window). Returns daily, referrers, devices, browsers, countries, heatmap (7×24 array), and max_days. |
+
| GET | +/api/links/:code/clicks/export | +Session | +Download raw click events as CSV. Columns: timestamp, referrer, device, browser, country. |
+
Stats, plan usage, tags, QR code generation, bulk operations, and CSV import/export.
+| Method | +Endpoint | +Auth | +Description | +
|---|---|---|---|
| GET | +/api/stats | +Session | +Dashboard stats: total links, total clicks, clicks in last 7 days, top 5 links, and a 30-day daily click array. |
+
| GET | +/api/plan | +Session | +Current user's effective plan, limits, and live usage — {plan, limits: {max_links, monthly_clicks, analytics_days}, usage: {active_links, monthly_clicks}}. Org members see pooled usage across all org members. |
+
| GET | +/api/tags | +Session | +Tags scoped to the requesting user — own tags for solo users and admins; all org member tags for org members. | +
| GET | +/api/fetch-title | +Session | +Server-side page title fetch. Query: ?url=<url>. Returns {"title": "…"}. |
+
| GET | +/api/qr/:code | +— | +QR PNG for a short link. Query params: ?fg=, ?bg=, ?size=, ?style=. |
+
| GET | +/api/qr/custom | +— | +QR PNG for any URL. Query: ?url=, ?fg=, ?bg=, ?size=, ?style=. Styles: square, rounded, dots, vertical, horizontal. |
+
| POST | +/api/qr/custom | +— | +QR PNG with logo overlay. Body: {url, fg, bg, size, style, logo} where logo is a base64-encoded image string. |
+
| POST | +/api/links/bulk | +Session | +Bulk operations. Body: {"action": "delete"|"tag"|"expire", "codes": […]}. |
+
| GET | +/api/links/export | +Session | +Download all links as CSV. | +
| POST | +/api/links/import | +Session | +Import links from CSV. Body: {"csv": "…"}. |
+
| GET | +/api/health | +— | +Health check — returns {"status": "ok"}. Used by Docker and uptime monitors. |
+
| GET | +/api/config | +— | +Public deployment config — returns non-sensitive values like app_name and base_url. |
+
All Admin endpoints require an authenticated admin session. These manage users, organizations, and the message inbox.
+| Method | +Endpoint | +Auth | +Description | +
|---|---|---|---|
| GET | +/api/admin/users | +Admin | +List all users with link counts, plan, org name, and org plan. | +
| POST | +/api/admin/users | +Admin | +Create user. Body: {"username": "…", "password": "…", "is_admin": bool, "plan": "…"}. |
+
| PATCH | +/api/admin/users/:id | +Admin | +Update plan, admin status, and/or org. Body: {plan?, is_admin?, org_id?}. Set org_id: null to remove from org. |
+
| DELETE | +/api/admin/users/:id | +Admin | +Delete user. Cannot delete yourself. | +
| PATCH | +/api/admin/users/:id/password | +Admin | +Change user password. Body: {"password": "…"}. |
+
| GET | +/api/admin/organizations | +Admin | +List all organizations with member counts and plan. | +
| POST | +/api/admin/organizations | +Admin | +Create organization. Body: {"name": "…", "plan": "…"}. |
+
| PATCH | +/api/admin/organizations/:id | +Admin | +Update organization name and/or plan. Body: {name?, plan?}. |
+
| DELETE | +/api/admin/organizations/:id | +Admin | +Delete organization. Members are unassigned but not deleted. | +
| GET | +/api/admin/messages | +Admin | +List all contact/portal messages, newest first. | +
| DELETE | +/api/admin/messages/:id | +Admin | +Delete a message. | +
| PATCH | +/api/admin/messages/:id/read | +Admin | +Mark a message as read. | +
Public endpoint for submitting contact and support messages. No authentication required.
+| Method | +Endpoint | +Auth | +Description | +
|---|---|---|---|
| POST | +/api/contact | +— | +Submit a contact message. Body: {"name": "…", "email": "…", "subject": "…", "body": "…"}. |
+
Short link redirection and SEO endpoints. No authentication required.
+| Method | +Endpoint | +Auth | +Description | +
|---|---|---|---|
| GET | +/:code | +— | +Redirect to the destination URL for a short code. Records click analytics (referrer, device, browser, country). | +
| GET | +/robots.txt | +— | +Standard robots.txt for crawler directives. | +
| GET | +/sitemap.xml | +— | +Auto-generated XML sitemap. | +
', methods=['GET'])
+@login_required
+def link_detail(code):
+ with get_db() as conn:
+ link = conn.execute('SELECT * FROM links WHERE code=?', (code,)).fetchone()
+ if not link or not _can_access_link(link, conn):
+ return jsonify({'error': 'Not found'}), 404
+ return jsonify(format_link(link, conn))
+
+
+@app.route('/api/links/', methods=['PATCH'])
+@login_required
+def edit_link(code):
+ with get_db() as conn:
+ link = conn.execute('SELECT * FROM links WHERE code=? AND is_active=1', (code,)).fetchone()
+ if not link or not _can_access_link(link, conn):
+ return jsonify({'error': 'Not found'}), 404
+
+ data = request.get_json(silent=True) or {}
+ updates = {}
+ if 'url' in data:
+ url = data['url'].strip()
+ if not validate_url(url): return jsonify({'error': 'Invalid URL'}), 400
+ updates['long_url'] = url
+ if 'title' in data: updates['title'] = data['title'].strip() or None
+ if 'expires_at' in data: updates['expires_at'] = data['expires_at'] or None
+ if 'is_pinned' in data: updates['is_pinned'] = 1 if data['is_pinned'] else 0
+
+ if updates:
+ set_clause = ', '.join(f'{k}=?' for k in updates)
+ conn.execute(f'UPDATE links SET {set_clause} WHERE code=?',
+ list(updates.values()) + [code])
+ if 'tags' in data:
+ set_link_tags(conn, link['id'], data['tags'], link['user_id'])
+
+ updated = conn.execute('SELECT * FROM links WHERE code=?', (code,)).fetchone()
+ return jsonify(format_link(updated, conn))
+
+
+@app.route('/api/links/', methods=['DELETE'])
+@login_required
+def delete_link(code):
+ with get_db() as conn:
+ link = conn.execute('SELECT * FROM links WHERE code=?', (code,)).fetchone()
+ if not link or not _can_access_link(link, conn):
+ return jsonify({'error': 'Not found'}), 404
+ conn.execute('UPDATE links SET is_active=0 WHERE code=?', (code,))
+ return jsonify({'success': True})
+
+
+# ─────────────────────────────────────────────
+# Analytics
+# ─────────────────────────────────────────────
+
+@app.route('/api/links//analytics')
+@login_required
+def link_analytics(code):
+ with get_db() as conn:
+ link = conn.execute('SELECT * FROM links WHERE code=?', (code,)).fetchone()
+ if not link or not _can_access_link(link, conn):
+ return jsonify({'error': 'Not found'}), 404
+
+ plan = get_user_plan(conn, session.get('user_id'))
+ max_days = PLAN_LIMITS[plan]['analytics_days']
+ days = min(int(request.args.get('days', 30)), max_days)
+
+ link_id = link['id']
+ since = (datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)).isoformat()
+
+ daily_rows = conn.execute("""
+ SELECT substr(clicked_at,1,10) as day, COUNT(*) as count
+ FROM clicks WHERE link_id=? AND clicked_at>=?
+ GROUP BY day ORDER BY day
+ """, (link_id, since)).fetchall()
+ daily_map = {r['day']: r['count'] for r in daily_rows}
+ daily = [
+ {'date': (datetime.now(timezone.utc).replace(tzinfo=None)-timedelta(days=days-1-i)).strftime('%Y-%m-%d'), 'clicks': 0}
+ for i in range(days)
+ ]
+ for d in daily: d['clicks'] = daily_map.get(d['date'], 0)
+
+ ref_rows = conn.execute(
+ 'SELECT referrer, COUNT(*) as count FROM clicks WHERE link_id=? AND clicked_at>=? GROUP BY referrer',
+ (link_id, since)
+ ).fetchall()
+ referrers = {}
+ for r in ref_rows:
+ b = parse_referrer(r['referrer']); referrers[b] = referrers.get(b,0) + r['count']
+ referrers = [{'source':k,'count':v} for k,v in sorted(referrers.items(), key=lambda x:-x[1])]
+
+ ua_rows = conn.execute(
+ 'SELECT user_agent, COUNT(*) as count FROM clicks WHERE link_id=? AND clicked_at>=? GROUP BY user_agent',
+ (link_id, since)
+ ).fetchall()
+ devices = {}; browsers = {}
+ for r in ua_rows:
+ ua = r['user_agent'] or ''
+ devices[parse_device(ua)] = devices.get(parse_device(ua),0) + r['count']
+ browsers[parse_browser(ua)] = browsers.get(parse_browser(ua),0) + r['count']
+ devices = [{'device':k,'count':v} for k,v in sorted(devices.items(), key=lambda x:-x[1])]
+ browsers = [{'browser':k,'count':v} for k,v in sorted(browsers.items(), key=lambda x:-x[1])]
+
+ # Hourly heatmap: 7 days-of-week × 24 hours
+ # SQLite strftime('%w') returns 0=Sunday … 6=Saturday; we map to 0=Monday … 6=Sunday
+ hourly_rows = conn.execute("""
+ SELECT CAST(strftime('%w', clicked_at) AS INTEGER) as dow,
+ CAST(strftime('%H', clicked_at) AS INTEGER) as hr,
+ COUNT(*) as count
+ FROM clicks WHERE link_id=? AND clicked_at>=?
+ GROUP BY dow, hr
+ """, (link_id, since)).fetchall()
+ # heatmap[day_of_week 0=Mon][hour 0-23]
+ heatmap = [[0] * 24 for _ in range(7)]
+ for r in hourly_rows:
+ mon_dow = (r['dow'] - 1) % 7 # 0=Sun→6, 1=Mon→0, …
+ heatmap[mon_dow][r['hr']] = r['count']
+
+ # Geographic breakdown
+ country_rows = conn.execute("""
+ SELECT COALESCE(NULLIF(country,''),'Unknown') as country, COUNT(*) as count
+ FROM clicks WHERE link_id=? AND clicked_at>=?
+ GROUP BY country ORDER BY count DESC LIMIT 20
+ """, (link_id, since)).fetchall()
+ countries = [{'country': r['country'], 'count': r['count']} for r in country_rows]
+
+ return jsonify({
+ 'code': code, 'days': days, 'max_days': max_days,
+ 'total_clicks': link['clicks'],
+ 'period_clicks': sum(d['clicks'] for d in daily),
+ 'daily': daily, 'referrers': referrers, 'devices': devices, 'browsers': browsers,
+ 'heatmap': heatmap, 'countries': countries,
+ })
+
+
+# ─────────────────────────────────────────────
+# Click-event CSV export
+# ─────────────────────────────────────────────
+
+@app.route('/api/links//clicks/export')
+@login_required
+def export_clicks(code):
+ with get_db() as conn:
+ link = conn.execute('SELECT * FROM links WHERE code=?', (code,)).fetchone()
+ if not link or not _can_access_link(link, conn):
+ return jsonify({'error': 'Not found'}), 404
+ rows = conn.execute(
+ 'SELECT clicked_at, referrer, user_agent, country FROM clicks '
+ 'WHERE link_id=? ORDER BY clicked_at DESC',
+ (link['id'],)
+ ).fetchall()
+ buf = io.StringIO()
+ writer = csv.writer(buf)
+ writer.writerow(['timestamp', 'referrer', 'device', 'browser', 'country'])
+ for row in rows:
+ ua = row['user_agent'] or ''
+ writer.writerow([
+ row['clicked_at'],
+ row['referrer'] or '',
+ parse_device(ua),
+ parse_browser(ua),
+ row['country'] or '',
+ ])
+ return Response(
+ buf.getvalue().encode('utf-8'),
+ mimetype='text/csv',
+ headers={'Content-Disposition': f'attachment; filename="clicks-{code}.csv"'}
+ )
+
+
+# ─────────────────────────────────────────────
+# Fetch Title
+# ─────────────────────────────────────────────
+
+@app.route('/api/fetch-title')
+@login_required
+def fetch_title():
+ """Fetch the page title for a URL server-side (avoids CORS)."""
+ import urllib.request as urllib_req
+ url = (request.args.get('url') or '').strip()
+ if not url or not validate_url(url):
+ return jsonify({'title': ''})
+ try:
+ req = urllib_req.Request(url, headers={
+ 'User-Agent': 'Mozilla/5.0 (compatible; QRknit-title-fetcher/1.0)',
+ 'Accept': 'text/html',
+ })
+ with urllib_req.urlopen(req, timeout=5) as resp:
+ ct = resp.headers.get('Content-Type', '')
+ if 'html' not in ct.lower():
+ return jsonify({'title': ''})
+ html = resp.read(65536).decode('utf-8', errors='replace')
+ # og:title (two attribute orderings)
+ m = re.search(r']+property=["\']og:title["\'][^>]+content=["\']([^"\']*)["\']', html, re.I)
+ if not m:
+ m = re.search(r']+content=["\']([^"\']*)["\'][^>]+property=["\']og:title["\']', html, re.I)
+ if m:
+ return jsonify({'title': m.group(1).strip()[:200]})
+ # Fall back to
+ m = re.search(r']*>([^<]+) ', html, re.I)
+ if m:
+ return jsonify({'title': m.group(1).strip()[:200]})
+ return jsonify({'title': ''})
+ except Exception:
+ return jsonify({'title': ''})
+
+
+# ─────────────────────────────────────────────
+# Plan usage
+# ─────────────────────────────────────────────
+
+@app.route('/api/plan')
+@login_required
+def plan_usage():
+ uid = session.get('user_id')
+ with get_db() as conn:
+ plan = get_user_plan(conn, uid)
+ link_count = get_user_active_link_count(conn, uid)
+ monthly_clicks = get_user_monthly_clicks(conn, uid)
+ limits = PLAN_LIMITS[plan]
+ return jsonify({
+ 'plan': plan,
+ 'limits': limits,
+ 'usage': {
+ 'active_links': link_count,
+ 'monthly_clicks': monthly_clicks,
+ },
+ })
+
+
+# ─────────────────────────────────────────────
+# Tags & Stats
+# ─────────────────────────────────────────────
+
+@app.route('/api/tags')
+@login_required
+def list_tags():
+ user_id = session.get('user_id')
+ is_admin = session.get('is_admin', False)
+ with get_db() as conn:
+ if is_admin:
+ # Admins see only their own tags
+ rows = conn.execute("""
+ SELECT t.id, t.name, COUNT(lt.link_id) as link_count
+ FROM tags t LEFT JOIN link_tags lt ON t.id=lt.tag_id
+ WHERE t.user_id=?
+ GROUP BY t.id ORDER BY t.name
+ """, (user_id,)).fetchall()
+ else:
+ org_id = get_user_org_id(conn, user_id)
+ if org_id:
+ # Org members see tags from all members of their org
+ rows = conn.execute("""
+ SELECT t.id, t.name, COUNT(lt.link_id) as link_count
+ FROM tags t LEFT JOIN link_tags lt ON t.id=lt.tag_id
+ WHERE t.user_id IN (SELECT id FROM users WHERE org_id=?)
+ GROUP BY t.id ORDER BY t.name
+ """, (org_id,)).fetchall()
+ else:
+ # Solo users see only their own tags
+ rows = conn.execute("""
+ SELECT t.id, t.name, COUNT(lt.link_id) as link_count
+ FROM tags t LEFT JOIN link_tags lt ON t.id=lt.tag_id
+ WHERE t.user_id=?
+ GROUP BY t.id ORDER BY t.name
+ """, (user_id,)).fetchall()
+ return jsonify({'tags': [dict(r) for r in rows]})
+
+
+@app.route('/api/stats')
+@login_required
+def stats():
+ is_admin = session.get('is_admin', False)
+ user_id = session.get('user_id')
+ with get_db() as conn:
+ org_id = get_user_org_id(conn, user_id)
+
+ # Determine the user_id filter clause and params
+ # Admins see only their own stats; org members pool across org members
+ if org_id and not is_admin:
+ scope_clause = 'l.user_id IN (SELECT id FROM users WHERE org_id=?)'
+ scope_param = org_id
+ else:
+ scope_clause = 'l.user_id=?'
+ scope_param = user_id
+
+ total_links = conn.execute(
+ f'SELECT COUNT(*) FROM links l WHERE is_active=1 AND {scope_clause}', (scope_param,)
+ ).fetchone()[0]
+ total_clicks = conn.execute(
+ f'SELECT COALESCE(SUM(clicks),0) FROM links l WHERE is_active=1 AND {scope_clause}', (scope_param,)
+ ).fetchone()[0]
+
+ since_7d = (datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=7)).isoformat()
+ since_30d = (datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=30)).isoformat()
+
+ clicks_7d = conn.execute(
+ f'SELECT COUNT(*) FROM clicks c JOIN links l ON c.link_id=l.id '
+ f'WHERE c.clicked_at>=? AND l.is_active=1 AND {scope_clause}', (since_7d, scope_param)
+ ).fetchone()[0]
+ top_links = conn.execute(
+ f'SELECT code, long_url, title, clicks FROM links l WHERE is_active=1 AND {scope_clause} '
+ f'ORDER BY clicks DESC LIMIT 5', (scope_param,)
+ ).fetchall()
+ daily_rows = conn.execute(f"""
+ SELECT substr(c.clicked_at,1,10) as day, COUNT(*) as count
+ FROM clicks c JOIN links l ON c.link_id=l.id
+ WHERE c.clicked_at>=? AND l.is_active=1 AND {scope_clause}
+ GROUP BY day ORDER BY day
+ """, (since_30d, scope_param)).fetchall()
+
+ daily_map = {r['day']: r['count'] for r in daily_rows}
+ daily = [
+ {'date': (datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=29-i)).strftime('%Y-%m-%d'), 'clicks': 0}
+ for i in range(30)
+ ]
+ for d in daily:
+ d['clicks'] = daily_map.get(d['date'], 0)
+
+ return jsonify({
+ 'total_links': total_links,
+ 'total_clicks': total_clicks,
+ 'clicks_7d': clicks_7d,
+ 'top_links': [dict(r) for r in top_links],
+ 'daily': daily,
+ })
+
+
+# ─────────────────────────────────────────────
+# QR Routes
+# ─────────────────────────────────────────────
+
+@app.route('/api/qr/')
+def qr_code(code):
+ fg_hex = request.args.get('fg', '000000')
+ bg_hex = request.args.get('bg', 'ffffff')
+ size = min(int(request.args.get('size', 300)), 1000)
+ style = request.args.get('style', 'square')
+ with get_db() as conn:
+ link = conn.execute('SELECT 1 FROM links WHERE code=? AND is_active=1', (code,)).fetchone()
+ if not link:
+ return jsonify({'error': 'Not found'}), 404
+ png = generate_qr_png(f"{BASE_URL}/{code}", size=size,
+ fg=hex_to_rgb(fg_hex), bg=hex_to_rgb(bg_hex), style=style)
+ return Response(png, mimetype='image/png', headers={'Cache-Control': 'public, max-age=3600'})
+
+
+@app.route('/api/qr/custom', methods=['GET'])
+def qr_custom():
+ url = request.args.get('url', '').strip()
+ if not url or not validate_url(url):
+ return jsonify({'error': 'Valid URL required'}), 400
+ fg_hex = request.args.get('fg', '000000')
+ bg_hex = request.args.get('bg', 'ffffff')
+ size = min(int(request.args.get('size', 300)), 1000)
+ style = request.args.get('style', 'square')
+ png = generate_qr_png(url, size=size, fg=hex_to_rgb(fg_hex), bg=hex_to_rgb(bg_hex), style=style)
+ return Response(png, mimetype='image/png')
+
+
+@app.route('/api/qr/custom', methods=['POST'])
+def qr_custom_post():
+ data = request.get_json(silent=True) or {}
+ url = (data.get('url') or '').strip()
+ if not url or not validate_url(url):
+ return jsonify({'error': 'Valid URL required'}), 400
+ fg_hex = (data.get('fg') or '000000').lstrip('#')
+ bg_hex = (data.get('bg') or 'ffffff').lstrip('#')
+ size = min(int(data.get('size', 300)), 1000)
+ style = data.get('style', 'square')
+ logo_bytes = None
+ logo_b64 = data.get('logo', '')
+ if logo_b64:
+ try:
+ logo_bytes = base64.b64decode(logo_b64)
+ except Exception:
+ return jsonify({'error': 'Invalid logo data'}), 400
+ png = generate_qr_png(url, size=size, fg=hex_to_rgb(fg_hex), bg=hex_to_rgb(bg_hex),
+ style=style, logo_bytes=logo_bytes)
+ return Response(png, mimetype='image/png')
+
+
+# ─────────────────────────────────────────────
+# Bulk Operations
+# ─────────────────────────────────────────────
+
+@app.route('/api/links/bulk', methods=['POST'])
+@login_required
+def bulk_links():
+ data = request.get_json(silent=True) or {}
+ action = data.get('action')
+ codes = data.get('codes', [])
+ if not codes:
+ return jsonify({'error': 'No codes provided'}), 400
+ if action not in ('delete', 'tag', 'expire'):
+ return jsonify({'error': 'Invalid action'}), 400
+
+ is_admin = session.get('is_admin', False)
+ user_id = session.get('user_id')
+ placeholders = ','.join('?' * len(codes))
+ with get_db() as conn:
+ if action == 'delete':
+ if is_admin:
+ conn.execute(f'UPDATE links SET is_active=0 WHERE code IN ({placeholders})', codes)
+ else:
+ conn.execute(
+ f'UPDATE links SET is_active=0 WHERE code IN ({placeholders}) AND user_id=?',
+ list(codes) + [user_id]
+ )
+ return jsonify({'deleted': len(codes)})
+
+ elif action == 'tag':
+ tags = data.get('tags', [])
+ user_org = session.get('org_id')
+ for code in codes:
+ q = 'SELECT id,user_id FROM links WHERE code=? AND is_active=1'
+ link = conn.execute(q, (code,)).fetchone()
+ if not link:
+ continue
+ same_org = False
+ if user_org and link['user_id']:
+ owner = conn.execute('SELECT org_id FROM users WHERE id=?', (link['user_id'],)).fetchone()
+ same_org = owner and owner['org_id'] == user_org
+ if is_admin or link['user_id'] == user_id or same_org:
+ set_link_tags(conn, link['id'], tags, link['user_id'])
+ return jsonify({'tagged': len(codes)})
+
+ elif action == 'expire':
+ expires_at = data.get('expires_at') or None
+ if is_admin:
+ conn.execute(
+ f'UPDATE links SET expires_at=? WHERE code IN ({placeholders})',
+ [expires_at] + list(codes)
+ )
+ else:
+ conn.execute(
+ f'UPDATE links SET expires_at=? WHERE code IN ({placeholders}) AND user_id=?',
+ [expires_at] + list(codes) + [user_id]
+ )
+ return jsonify({'updated': len(codes)})
+
+
+# ─────────────────────────────────────────────
+# CSV Export / Import
+# ─────────────────────────────────────────────
+
+@app.route('/api/links/export')
+@login_required
+def export_links():
+ is_admin = session.get('is_admin', False)
+ user_id = session.get('user_id')
+ with get_db() as conn:
+ if is_admin:
+ rows = conn.execute(
+ 'SELECT l.*, GROUP_CONCAT(t.name) as tag_names '
+ 'FROM links l '
+ 'LEFT JOIN link_tags lt ON l.id=lt.link_id '
+ 'LEFT JOIN tags t ON lt.tag_id=t.id '
+ 'WHERE l.is_active=1 '
+ 'GROUP BY l.id ORDER BY l.created_at DESC'
+ ).fetchall()
+ else:
+ rows = conn.execute(
+ 'SELECT l.*, GROUP_CONCAT(t.name) as tag_names '
+ 'FROM links l '
+ 'LEFT JOIN link_tags lt ON l.id=lt.link_id '
+ 'LEFT JOIN tags t ON lt.tag_id=t.id '
+ 'WHERE l.is_active=1 AND l.user_id=? '
+ 'GROUP BY l.id ORDER BY l.created_at DESC',
+ (user_id,)
+ ).fetchall()
+
+ buf = io.StringIO()
+ writer = csv.writer(buf)
+ writer.writerow(['code', 'short_url', 'long_url', 'title', 'tags', 'created_at', 'expires_at', 'clicks'])
+ for row in rows:
+ writer.writerow([
+ row['code'],
+ f"{BASE_URL}/{row['code']}",
+ row['long_url'],
+ row['title'] or '',
+ row['tag_names'] or '',
+ row['created_at'],
+ row['expires_at'] or '',
+ row['clicks'],
+ ])
+ return Response(
+ buf.getvalue().encode('utf-8'),
+ mimetype='text/csv',
+ headers={'Content-Disposition': f'attachment; filename="{re.sub(r"[^a-z0-9]+", "-", APP_NAME.lower()).strip("-")}-export.csv"'}
+ )
+
+
+@app.route('/api/links/import', methods=['POST'])
+@login_required
+def import_links():
+ data = request.get_json(silent=True) or {}
+ csv_text = (data.get('csv') or '').strip()
+ if not csv_text:
+ return jsonify({'error': 'No CSV data provided'}), 400
+
+ reader = csv.DictReader(io.StringIO(csv_text))
+ created = 0
+ errors = []
+
+ with get_db() as conn:
+ for i, row in enumerate(reader, start=2):
+ url = (row.get('url') or row.get('long_url') or '').strip()
+ if not url:
+ errors.append(f'Row {i}: missing URL'); continue
+ if not validate_url(url):
+ errors.append(f'Row {i}: invalid URL "{url[:50]}"'); continue
+
+ custom_code = (row.get('custom_code') or row.get('code') or '').strip()
+ title = (row.get('title') or '').strip()
+ expires_at = (row.get('expires_at') or '').strip() or None
+ tags_str = (row.get('tags') or '').strip()
+ tag_names = [t.strip() for t in tags_str.split(',') if t.strip()] if tags_str else []
+
+ if custom_code and not re.match(r'^[a-zA-Z0-9]{1,20}$', custom_code):
+ errors.append(f'Row {i}: invalid code "{custom_code}"'); continue
+
+ code = custom_code or generate_code(url)
+ if conn.execute('SELECT 1 FROM links WHERE code=?', (code,)).fetchone():
+ if custom_code:
+ errors.append(f'Row {i}: code "{custom_code}" already taken'); continue
+ code = generate_code(url + str(time.time()))
+
+ conn.execute(
+ 'INSERT INTO links (code, long_url, title, created_at, expires_at, user_id) VALUES (?,?,?,?,?,?)',
+ (code, url, title or None,
+ datetime.now(timezone.utc).replace(tzinfo=None).isoformat(), expires_at,
+ session.get('user_id'))
+ )
+ link_id = conn.execute('SELECT id FROM links WHERE code=?', (code,)).fetchone()['id']
+ if tag_names:
+ set_link_tags(conn, link_id, tag_names, session.get('user_id'))
+ created += 1
+
+ return jsonify({'created': created, 'errors': errors})
+
+
+# ─────────────────────────────────────────────
+# Admin — User Management
+# ─────────────────────────────────────────────
+
+@app.route('/api/admin/users', methods=['GET'])
+@admin_required
+def admin_list_users():
+ with get_db() as conn:
+ rows = conn.execute(
+ 'SELECT u.id, u.username, u.is_admin, u.plan, u.created_at, u.org_id, '
+ 'o.name as org_name, o.plan as org_plan, '
+ '(SELECT COUNT(*) FROM links WHERE user_id=u.id AND is_active=1) as link_count '
+ 'FROM users u LEFT JOIN organizations o ON u.org_id=o.id '
+ 'ORDER BY u.created_at ASC'
+ ).fetchall()
+ return jsonify({'users': [dict(r) for r in rows]})
+
+
+@app.route('/api/admin/users', methods=['POST'])
+@admin_required
+def admin_create_user():
+ data = request.get_json(silent=True) or {}
+ username = (data.get('username') or '').strip()
+ password = (data.get('password') or '').strip()
+ is_admin = bool(data.get('is_admin', False))
+ plan = (data.get('plan') or 'free').lower()
+ if plan not in VALID_PLANS or plan == 'admin':
+ plan = 'free'
+ if not username or not password:
+ return jsonify({'error': 'Username and password required'}), 400
+ if not re.match(r'^[a-zA-Z0-9_.-]{2,32}$', username):
+ return jsonify({'error': 'Username must be 2–32 alphanumeric/._- characters'}), 400
+ with get_db() as conn:
+ if conn.execute('SELECT 1 FROM users WHERE username=?', (username,)).fetchone():
+ return jsonify({'error': 'Username already taken'}), 409
+ conn.execute(
+ 'INSERT INTO users (username, password_hash, is_admin, plan, created_at) VALUES (?,?,?,?,?)',
+ (username, generate_password_hash(password), 1 if is_admin else 0, plan,
+ datetime.now(timezone.utc).replace(tzinfo=None).isoformat())
+ )
+ user = conn.execute('SELECT id, username, is_admin, plan, created_at FROM users WHERE username=?',
+ (username,)).fetchone()
+ return jsonify(dict(user)), 201
+
+
+@app.route('/api/admin/users/', methods=['PATCH'])
+@admin_required
+def admin_update_user(user_id):
+ data = request.get_json(silent=True) or {}
+ updates = {}
+ if 'plan' in data:
+ plan = (data['plan'] or 'free').lower()
+ if plan not in VALID_PLANS or plan == 'admin':
+ return jsonify({'error': f'Invalid plan. Valid plans: {", ".join(p for p in VALID_PLANS if p != "admin")}'}), 400
+ updates['plan'] = plan
+ if 'is_admin' in data:
+ if user_id == session.get('user_id'):
+ return jsonify({'error': 'Cannot change your own admin status'}), 400
+ updates['is_admin'] = 1 if data['is_admin'] else 0
+ if 'org_id' in data:
+ org_id = data['org_id'] # None to remove from org
+ updates['org_id'] = org_id
+ if not updates:
+ return jsonify({'error': 'No valid fields to update'}), 400
+ with get_db() as conn:
+ if not conn.execute('SELECT 1 FROM users WHERE id=?', (user_id,)).fetchone():
+ return jsonify({'error': 'Not found'}), 404
+ if 'org_id' in updates and updates['org_id'] is not None:
+ if not conn.execute('SELECT 1 FROM organizations WHERE id=?', (updates['org_id'],)).fetchone():
+ return jsonify({'error': 'Organization not found'}), 404
+ set_clause = ', '.join(f'{k}=?' for k in updates)
+ conn.execute(f'UPDATE users SET {set_clause} WHERE id=?',
+ list(updates.values()) + [user_id])
+ user = conn.execute(
+ 'SELECT u.id, u.username, u.is_admin, u.plan, u.created_at, u.org_id, '
+ 'o.name as org_name FROM users u LEFT JOIN organizations o ON u.org_id=o.id '
+ 'WHERE u.id=?', (user_id,)
+ ).fetchone()
+ return jsonify(dict(user))
+
+
+@app.route('/api/admin/users/', methods=['DELETE'])
+@admin_required
+def admin_delete_user(user_id):
+ if user_id == session.get('user_id'):
+ return jsonify({'error': 'Cannot delete your own account'}), 400
+ with get_db() as conn:
+ user = conn.execute('SELECT * FROM users WHERE id=?', (user_id,)).fetchone()
+ if not user:
+ return jsonify({'error': 'Not found'}), 404
+ conn.execute('DELETE FROM users WHERE id=?', (user_id,))
+ return jsonify({'success': True})
+
+
+@app.route('/api/admin/users//password', methods=['PATCH'])
+@admin_required
+def admin_change_password(user_id):
+ data = request.get_json(silent=True) or {}
+ password = (data.get('password') or '').strip()
+ if not password:
+ return jsonify({'error': 'Password required'}), 400
+ with get_db() as conn:
+ if not conn.execute('SELECT 1 FROM users WHERE id=?', (user_id,)).fetchone():
+ return jsonify({'error': 'Not found'}), 404
+ conn.execute('UPDATE users SET password_hash=? WHERE id=?',
+ (generate_password_hash(password), user_id))
+ return jsonify({'success': True})
+
+
+# ─────────────────────────────────────────────
+# Contact / Portal messages
+# ─────────────────────────────────────────────
+
+@app.route('/api/contact', methods=['POST'])
+def submit_contact():
+ data = request.get_json(silent=True) or {}
+ name = (data.get('name') or '').strip()
+ email = (data.get('email') or '').strip()
+ subject = (data.get('subject') or '').strip()
+ body = (data.get('body') or '').strip()
+ if not name or not email or not subject:
+ return jsonify({'error': 'name, email and subject are required'}), 400
+ now = datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
+ with get_db() as conn:
+ conn.execute(
+ 'INSERT INTO messages (name, email, subject, body, created_at) VALUES (?,?,?,?,?)',
+ (name, email, subject, body, now)
+ )
+ return jsonify({'success': True})
+
+
+@app.route('/api/admin/messages', methods=['GET'])
+@admin_required
+def admin_list_messages():
+ with get_db() as conn:
+ rows = conn.execute(
+ 'SELECT * FROM messages ORDER BY created_at DESC'
+ ).fetchall()
+ return jsonify([dict(r) for r in rows])
+
+
+@app.route('/api/admin/messages/', methods=['DELETE'])
+@admin_required
+def admin_delete_message(msg_id):
+ with get_db() as conn:
+ if not conn.execute('SELECT 1 FROM messages WHERE id=?', (msg_id,)).fetchone():
+ return jsonify({'error': 'Not found'}), 404
+ conn.execute('DELETE FROM messages WHERE id=?', (msg_id,))
+ return jsonify({'success': True})
+
+
+@app.route('/api/admin/messages//read', methods=['PATCH'])
+@admin_required
+def admin_mark_message_read(msg_id):
+ with get_db() as conn:
+ conn.execute('UPDATE messages SET is_read=1 WHERE id=?', (msg_id,))
+ return jsonify({'success': True})
+
+
+# ─────────────────────────────────────────────
+# Admin — Organization Management
+# ─────────────────────────────────────────────
+
+@app.route('/api/admin/organizations', methods=['GET'])
+@admin_required
+def admin_list_organizations():
+ with get_db() as conn:
+ rows = conn.execute(
+ 'SELECT o.id, o.name, o.plan, o.created_at, '
+ 'COUNT(u.id) as member_count '
+ 'FROM organizations o LEFT JOIN users u ON u.org_id=o.id '
+ 'GROUP BY o.id ORDER BY o.name ASC'
+ ).fetchall()
+ return jsonify({'organizations': [dict(r) for r in rows]})
+
+
+@app.route('/api/admin/organizations', methods=['POST'])
+@admin_required
+def admin_create_organization():
+ data = request.get_json(silent=True) or {}
+ name = (data.get('name') or '').strip()
+ plan = (data.get('plan') or 'team').lower()
+ if not name:
+ return jsonify({'error': 'Organization name required'}), 400
+ if plan not in VALID_PLANS or plan == 'admin':
+ plan = 'team'
+ now = datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
+ with get_db() as conn:
+ if conn.execute('SELECT 1 FROM organizations WHERE name=?', (name,)).fetchone():
+ return jsonify({'error': 'Organization name already taken'}), 409
+ conn.execute('INSERT INTO organizations (name, plan, created_at) VALUES (?,?,?)',
+ (name, plan, now))
+ org = conn.execute(
+ 'SELECT id, name, plan, created_at FROM organizations WHERE name=?', (name,)
+ ).fetchone()
+ return jsonify(dict(org)), 201
+
+
+@app.route('/api/admin/organizations/', methods=['PATCH'])
+@admin_required
+def admin_update_organization(org_id):
+ data = request.get_json(silent=True) or {}
+ updates = {}
+ if 'name' in data:
+ updates['name'] = (data['name'] or '').strip()
+ if not updates['name']:
+ return jsonify({'error': 'Name cannot be empty'}), 400
+ if 'plan' in data:
+ plan = (data['plan'] or 'team').lower()
+ if plan not in VALID_PLANS or plan == 'admin':
+ return jsonify({'error': 'Invalid plan'}), 400
+ updates['plan'] = plan
+ if not updates:
+ return jsonify({'error': 'No valid fields to update'}), 400
+ with get_db() as conn:
+ if not conn.execute('SELECT 1 FROM organizations WHERE id=?', (org_id,)).fetchone():
+ return jsonify({'error': 'Not found'}), 404
+ set_clause = ', '.join(f'{k}=?' for k in updates)
+ conn.execute(f'UPDATE organizations SET {set_clause} WHERE id=?',
+ list(updates.values()) + [org_id])
+ org = conn.execute(
+ 'SELECT o.id, o.name, o.plan, o.created_at, COUNT(u.id) as member_count '
+ 'FROM organizations o LEFT JOIN users u ON u.org_id=o.id WHERE o.id=?',
+ (org_id,)
+ ).fetchone()
+ return jsonify(dict(org))
+
+
+@app.route('/api/admin/organizations/', methods=['DELETE'])
+@admin_required
+def admin_delete_organization(org_id):
+ with get_db() as conn:
+ if not conn.execute('SELECT 1 FROM organizations WHERE id=?', (org_id,)).fetchone():
+ return jsonify({'error': 'Not found'}), 404
+ # Remove org_id from all members before deleting
+ conn.execute('UPDATE users SET org_id=NULL WHERE org_id=?', (org_id,))
+ conn.execute('DELETE FROM organizations WHERE id=?', (org_id,))
+ return jsonify({'success': True})
+
+
+# ─────────────────────────────────────────────
+# SEO — robots.txt & sitemap.xml
+# ─────────────────────────────────────────────
+
+@app.route('/robots.txt')
+def robots_txt():
+ body = f"""User-agent: *
+Allow: /
+Allow: /landing-os
+
+Disallow: /app
+Disallow: /api/
+Disallow: /static/
+
+Sitemap: {BASE_URL}/sitemap.xml
+"""
+ return Response(body, mimetype='text/plain')
+
+
+@app.route('/sitemap.xml')
+def sitemap_xml():
+ from datetime import date
+ today = date.today().isoformat()
+ body = f"""
+
+
+ {BASE_URL}/
+ {today}
+ weekly
+ 1.0
+
+
+ {BASE_URL}/landing-os
+ {today}
+ monthly
+ 0.7
+
+
+ {BASE_URL}/api-docs
+ {today}
+ monthly
+ 0.6
+
+ """
+ return Response(body, mimetype='application/xml')
+
+
+# ─────────────────────────────────────────────
+# Redirect
+# ─────────────────────────────────────────────
+
+@app.route('/')
+def redirect_link(code):
+ if code in ('static', 'api', 'favicon.ico', 'robots.txt', 'sitemap.xml'):
+ return 'Not found', 404
+ with get_db() as conn:
+ link = conn.execute('SELECT * FROM links WHERE code=? AND is_active=1', (code,)).fetchone()
+ if not link:
+ return redirect('/?error=not_found')
+ if link['expires_at'] and link['expires_at'] < datetime.now(timezone.utc).replace(tzinfo=None).isoformat():
+ return redirect('/?error=expired')
+
+ # Monthly click quota check (skip for links with no owner)
+ if link['user_id']:
+ plan = get_user_plan(conn, link['user_id'])
+ click_limit = PLAN_LIMITS[plan]['monthly_clicks']
+ if click_limit is not None and get_user_monthly_clicks(conn, link['user_id']) >= click_limit:
+ return redirect('/?error=quota_exceeded')
+
+ country = get_country_for_request()
+ client_ip = get_client_ip()
+ conn.execute(
+ 'INSERT INTO clicks (link_id,clicked_at,referrer,user_agent,ip_address,country) VALUES (?,?,?,?,?,?)',
+ (link['id'], datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
+ request.referrer, request.headers.get('User-Agent','')[:500],
+ client_ip[:45], country)
+ )
+ conn.execute('UPDATE links SET clicks=clicks+1 WHERE id=?', (link['id'],))
+ return redirect(link['long_url'], code=301)
+
+
+# ─────────────────────────────────────────────
+# Frontend routes
+# ─────────────────────────────────────────────
+
+@app.route('/')
+def landing():
+ with open(os.path.join(os.path.dirname(__file__), 'landing.html')) as f:
+ return f.read()
+
+@app.route('/landing-os')
+def landing_os():
+ with open(os.path.join(os.path.dirname(__file__), 'landing-os.html')) as f:
+ return f.read()
+
+@app.route('/api-docs')
+def api_docs():
+ with open(os.path.join(os.path.dirname(__file__), 'api-docs.html')) as f:
+ return f.read()
+
+@app.route('/app', defaults={'subpath': ''})
+@app.route('/app/')
+def app_frontend(subpath):
+ with open(os.path.join(os.path.dirname(__file__), 'index.html')) as f:
+ return f.read()
+
+
+if __name__ == '__main__':
+ port = int(os.environ.get('PORT', 5000))
+ app.run(host='0.0.0.0', port=port,
+ debug=os.environ.get('DEBUG', 'false').lower() == 'true')
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..3056b1f
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,48 @@
+# ─────────────────────────────────────────────
+# QRknit — docker-compose.yml
+# For local dev or non-Unraid deployments
+# On Unraid: use the Docker tab UI instead
+# ─────────────────────────────────────────────
+
+services:
+ qrknit:
+ build: .
+ # Or use a pre-built image from Docker Hub:
+ # image: yourdockerhubuser/qrknit:latest
+ container_name: qrknit
+ restart: unless-stopped
+
+ ports:
+ - "5000:5000" # Change left side to use a different host port
+ # e.g. "80:5000" to serve on port 80
+
+ volumes:
+ - qrknit-data:/app/data # Persistent SQLite database
+
+ environment:
+ - BASE_URL=https://yourdomain.com # ← Your public URL (domain or subdomain)
+ - APP_NAME=My.Links # ← Displayed in the UI header/logo/title
+ - ADMIN_USERNAME=admin # ← Admin account username (default: admin)
+ - PORT=5000
+ - DEBUG=false
+ - SECRET_KEY=replace-with-a-long-random-string # ← Change this!
+ - ADMIN_PASSWORD=replace-with-a-strong-password # ← Change this!
+ - COOKIE_SECURE=false # Set to true only if Flask receives HTTPS directly (not behind a proxy)
+
+ # Optional: resource limits
+ deploy:
+ resources:
+ limits:
+ cpus: '0.5'
+ memory: 256M
+
+ healthcheck:
+ test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 15s
+
+volumes:
+ qrknit-data:
+ driver: local