1
0
forked from w0bm/f0bm

51 Commits

Author SHA1 Message Date
x
ee416a1d08 feat: Add required Terms of Service acceptance to registration, enhance terms page content, and introduce utility scripts for thumbnail and dummy data generation. 2026-01-24 16:32:28 +01:00
x
2ad318e7c5 feat: Display login success messages, adjust user registration defaults and post-registration redirect, and include scripts for generating dummy items and copying thumbnails. 2026-01-24 16:11:56 +01:00
x
16da3ac9d0 feat: Add invite token-based user registration and an admin interface for token management. 2026-01-24 16:01:40 +01:00
x
1b1867332b feat: Introduce scripts for generating dummy items and copying thumbnails, and flatten UI elements by removing border-radius in CSS. 2026-01-24 15:28:03 +01:00
x
d8979b6b1a feat: introduce utility scripts for thumbnail management and dummy data generation, and remove rounded corners from upload UI. 2026-01-24 15:26:46 +01:00
x
c9ca037063 feat: Enhance upload page tag suggestions with keyboard navigation and a top-aligned dropdown, while adding utility scripts for thumbnail copying and dummy data generation. 2026-01-24 15:16:53 +01:00
x
111f06ed42 adding login page to navbar 2026-01-24 13:37:13 +01:00
x
8397d4ed3f improving upload page and conent guidelines 2026-01-24 12:05:10 +01:00
x
f2b14739e3 lights on/off play/pause hotkeys (re)added 2026-01-24 11:24:00 +01:00
x
fc7d38e3f1 mono theme, keep it black and simple 2026-01-24 11:12:25 +01:00
x
2229f32dd3 potential fix for the last fucked up commits xd 2026-01-24 10:58:22 +01:00
x
9c9309435d potential fix for comma seperated tags in view 2026-01-24 10:45:21 +01:00
x
446e9149bd potential fix for comma seperated tag view randoming 2026-01-24 10:22:22 +01:00
x
f488559e2e change delete redirect from main page to /random !! 2026-01-24 09:11:54 +01:00
x
d691680682 removing mobile swipe funcktionality 2026-01-24 09:04:02 +01:00
x
f950726ce6 adding recreate hashes debug script 2026-01-24 08:40:42 +01:00
x
54f266ff3d adding shortcuts 2026-01-24 01:38:25 +01:00
x
a9871187ab numeric tag entry point fix potential 2026-01-24 01:35:30 +01:00
x
43da214f73 fixing upload 2026-01-24 01:28:14 +01:00
x
c822a4f4e7 possible user fav fix 2026-01-24 01:16:49 +01:00
x
a8bb3e67f5 changin rand buton style 2026-01-24 01:11:03 +01:00
x
85912f4ba1 LUPE KLEINER! 2026-01-24 00:43:46 +01:00
x
f3a1fde23d commenting out rand button in navbar 2026-01-24 00:42:33 +01:00
x
8085b0166c making search icon smaller 2026-01-24 00:41:33 +01:00
x
85578b179b clean up deleted q 2026-01-24 00:14:25 +01:00
x
1a3514effa more possible fixes for uploading 2026-01-23 23:44:50 +01:00
x
a439683caf possible upload fix 2026-01-23 23:42:23 +01:00
x
577d73af11 realizing webupload with approval functionality 2026-01-23 23:35:12 +01:00
x
42f4e19897 remove debug output for fav randoming 2026-01-23 22:09:58 +01:00
x
0a5f57b5a9 another possible fix for fav randoming 2026-01-23 22:06:57 +01:00
x
03f2630090 potential fix for mixed random results when unathenticated 2026-01-23 22:00:49 +01:00
x
6692f32c4b possible fix for random fav behaviour 2026-01-23 21:52:34 +01:00
x
8af49b6ec1 improving fav detection logic 2026-01-23 21:38:47 +01:00
x
9c25f89adc adding a better navbar 2026-01-23 21:31:06 +01:00
x
ee6fda8f06 new modal for deleting tags and items 2026-01-23 20:52:49 +01:00
x
e9c377dc87 fixing random not working for user fav view 2026-01-23 20:28:03 +01:00
x
f5e386593d fixing tag image encoding 2026-01-23 20:08:38 +01:00
x
1dd4b54b48 change how tags are displayed in tag image 2026-01-23 19:52:39 +01:00
x
4de2652ffe adding cool search 2026-01-23 19:44:17 +01:00
7b1e0af0cb Merge pull request 'fixing background visibility and states' (#5) from eins-f0bm into f0bm
Reviewed-on: w0bm/f0bm#5
2026-01-23 17:38:20 +00:00
x
224064d0ca fixing background visibility and states 2026-01-23 18:37:44 +01:00
52533486a2 Merge pull request 'eins-f0bm' (#4) from eins-f0bm into f0bm
Reviewed-on: w0bm/f0bm#4
2026-01-23 16:05:04 +00:00
x
3ee28fd0b7 Merge branch 'f0bm' into eins-f0bm 2026-01-23 17:03:13 +01:00
eins
964284d5c9 REAL seamless now! 2026-01-23 15:53:45 +00:00
x
9a03d5f697 adding generic tag cards 2026-01-23 16:53:19 +01:00
eins
9b1041dda7 first version of infinite scrolling 2026-01-23 15:45:28 +00:00
4bc8b8f436 Merge pull request 'fixed issues with the random button and hotkeys' (#2) from eins/f0bm:f0bm into f0bm
Reviewed-on: w0bm/f0bm#2
2026-01-23 14:53:20 +00:00
eins
45f9345e9c fix r key on tag overview 2026-01-23 14:46:39 +00:00
eins
c74e5a7402 fixed scrolling in overview 2026-01-23 14:39:42 +00:00
eins
6799ec1567 fixed issues with the random button and hotkeys 2026-01-23 14:07:52 +00:00
007cf3189c Merge pull request 'added AJAX loading for videos' (#1) from eins/f0bm:eins-patch-1 into f0bm
Reviewed-on: w0bm/f0bm#1
Reviewed-by: Kibi Kelburton <schrumpel@noreply.DOMAIN>
2026-01-23 13:33:59 +00:00
33 changed files with 3907 additions and 660 deletions

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@ deleted/b
deleted/ca deleted/ca
deleted/t deleted/t
tmp/* tmp/*
tools

84
debug/recreate_hashes.mjs Normal file
View File

@@ -0,0 +1,84 @@
import fs from 'fs';
import crypto from 'crypto';
import db from '../src/inc/sql.mjs';
import path from 'path';
const run = async () => {
console.log('Starting hash recreation (Production Mode - Streams)...');
try {
// Fetch only necessary columns
const items = await db`SELECT id, dest, checksum, size FROM items ORDER BY id ASC`;
console.log(`Found ${items.length} items. Processing...`);
let updated = 0;
let errors = 0;
let skipped = 0;
for (const [index, item] of items.entries()) {
const filePath = path.join('./public/b', item.dest);
try {
if (!fs.existsSync(filePath)) {
// Silent error in logs for missing files to avoid spamming "thousands" of lines if many are missing
// Use verbose logging if needed, but here we'll just count them.
// Actually, precise logs are better for "production" to know what's wrong.
console.error(`[MISSING] File not found for item ${item.id}: ${filePath}`);
errors++;
continue;
}
// Get file size without reading content
const stats = await fs.promises.stat(filePath);
const size = stats.size;
// Calculate hash using stream to ensure low memory usage
const hash = await new Promise((resolve, reject) => {
const hashStream = crypto.createHash('sha256');
const rs = fs.createReadStream(filePath);
rs.on('error', reject);
rs.on('data', chunk => hashStream.update(chunk));
rs.on('end', () => resolve(hashStream.digest('hex')));
});
if (hash !== item.checksum || size !== item.size) {
console.log(`[UPDATE] Item ${item.id} (${index + 1}/${items.length})`);
if (hash !== item.checksum) console.log(` - Hash: ${item.checksum} -> ${hash}`);
if (size !== item.size) console.log(` - Size: ${item.size} -> ${size}`);
await db`
UPDATE items
SET checksum = ${hash}, size = ${size}
WHERE id = ${item.id}
`;
updated++;
} else {
skipped++;
}
// Log progress every 100 items
if ((index + 1) % 100 === 0) {
console.log(`Progress: ${index + 1}/${items.length} (Updated: ${updated}, Errors: ${errors})`);
}
} catch (err) {
console.error(`[ERROR] Processing item ${item.id}:`, err);
errors++;
}
}
console.log('Done.');
console.log(`Total: ${items.length}`);
console.log(`Updated: ${updated}`);
console.log(`Skipped (No changes): ${skipped}`);
console.log(`Errors (Missing files): ${errors}`);
} catch (err) {
console.error('Fatal error:', err);
} finally {
process.exit(0);
}
};
run();

View File

@@ -1197,7 +1197,9 @@ body {
overscroll-behavior-y: contain; overscroll-behavior-y: contain;
overflow: unset; overflow: unset;
font-size: 14px; font-size: 14px;
height: /* 100%; */auto; height:
/* 100%; */
auto;
} }
.wrapper { .wrapper {
@@ -1318,6 +1320,13 @@ div.posts>a:hover::after {
grid-template-columns: auto 1fr 0fr; grid-template-columns: auto 1fr 0fr;
justify-content: start; justify-content: start;
border-bottom: 1px solid var(--nav-border-color); border-bottom: 1px solid var(--nav-border-color);
background: var(--nav-bg);
transition: background 0.2s ease;
}
.navbar.scrolled {
background: #000 !important;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
} }
.navbar-brand { .navbar-brand {
@@ -1424,7 +1433,7 @@ ul.navbar-nav li.nav-item {
} }
.nav-link[data-toggle="dropdown"].ddcontent::after { .nav-link[data-toggle="dropdown"].ddcontent::after {
content: "\00a0("attr(content) ")\00a0\25bc"; content: "\00a0(" attr(content) ")\00a0\25bc";
} }
.nav-link[data-toggle="dropdown"]:not(.ddcontent)::after { .nav-link[data-toggle="dropdown"]:not(.ddcontent)::after {
@@ -1576,14 +1585,10 @@ span.placeholder {
} }
@media (max-width: 1056px) { @media (max-width: 1056px) {
.navbar {
display: grid;
grid-template-rows: 1fr 1fr;
grid-template-areas: 'f0ck f0ck f0ck';
}
/* Navbar grid layout removed for modern-navbar compatibility */
.navbar-brand { .navbar-brand {
grid-area: f0ck; /* maintained for potential other uses or reset */
} }
.pagination-container-fluid { .pagination-container-fluid {
@@ -1608,7 +1613,7 @@ span.placeholder {
} }
@media (max-width: 1325px) { @media (max-width: 1325px) {
/* ranking page - idea */ /* ranking page - idea */
/* .ranking { /* .ranking {
grid-template-columns: 1fr 1fr !important; grid-template-columns: 1fr 1fr !important;
} */ } */
@@ -1783,8 +1788,9 @@ span.placeholder {
.index-container { .index-container {
width: 100%; width: 100%;
padding: 5px; padding: 5px;
/* background-color: var(--navigation-links-bg); /* background-color: var(--navigation-links-bg);
*/} */
}
@media (min-width: 361px) { @media (min-width: 361px) {
.embed-responsive-image { .embed-responsive-image {
@@ -2198,7 +2204,7 @@ body[type='login'] {
align-items: center; align-items: center;
padding: 20px 20px; padding: 20px 20px;
background: rgba(0, 0, 0, 0.8); background: rgba(0, 0, 0, 0.8);
border-radius: 10px; border-radius: 0;
border: 1px solid var(--accent); border: 1px solid var(--accent);
} }
@@ -2779,7 +2785,9 @@ ul.navbar-nav-guests li.nav-item {
margin-right: .5rem; margin-right: .5rem;
} }
.navbar-expand-lg .navbar-nav-guests .nav-link, .pagination > a, .pagination > span { .navbar-expand-lg .navbar-nav-guests .nav-link,
.pagination>a,
.pagination>span {
padding-right: .5rem; padding-right: .5rem;
padding-left: .5rem; padding-left: .5rem;
} }
@@ -2792,7 +2800,7 @@ ul.navbar-nav-guests li.nav-item {
margin: 0; margin: 0;
padding: 5px; padding: 5px;
} }
ul.navbar-nav-guests li.nav-item { ul.navbar-nav-guests li.nav-item {
margin-right: unset; margin-right: unset;
} }
@@ -2812,16 +2820,20 @@ ul.navbar-nav-guests li.nav-item {
/* Pagination Responsiveness */ /* Pagination Responsiveness */
@media (max-width: 799px) { @media (max-width: 799px) {
.navbar-expand-lg .navbar-nav-guests .nav-link, .pagination > a, .pagination > span {
.navbar-expand-lg .navbar-nav-guests .nav-link,
.pagination>a,
.pagination>span {
padding-right: 2px; padding-right: 2px;
padding-left: 2px; padding-left: 2px;
} }
.pagination > a, .pagination > span { .pagination>a,
.pagination>span {
margin-right: 2px; margin-right: 2px;
margin-left: 2px; margin-left: 2px;
} }
.pagination { .pagination {
justify-content: center !important; justify-content: center !important;
} }
@@ -2830,15 +2842,19 @@ ul.navbar-nav-guests li.nav-item {
/* fadeIn effect */ /* fadeIn effect */
@keyframes fadeIn { @keyframes fadeIn {
0% { 0% {
opacity: 0; opacity: 0;
} }
100% { 100% {
opacity: 1; opacity: 1;
} }
} }
img#f0ck-image, div.imageDoor, div.posts a, video { img#f0ck-image,
animation: 1s ease-out 0s 1 fadeInFX; div.imageDoor,
div.posts a,
video {
animation: 1s ease-out 0s 1 fadeInFX;
} }
/* f0ckgle */ /* f0ckgle */
@@ -2867,7 +2883,7 @@ img#f0ck-image, div.imageDoor, div.posts a, video {
background: var(--nav-bg); background: var(--nav-bg);
padding: 5px; padding: 5px;
} }
.profile_head_avatar { .profile_head_avatar {
margin: 0; margin: 0;
} }
@@ -2892,11 +2908,13 @@ img#f0ck-image, div.imageDoor, div.posts a, video {
padding: 5px; padding: 5px;
} }
.f0cks h5, .favs h5 { .f0cks h5,
.favs h5 {
background: var(--dropdown-bg); background: var(--dropdown-bg);
} }
.f0cks-header, .favs-header { .f0cks-header,
.favs-header {
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
background: var(--img-border-color); background: var(--img-border-color);
@@ -2925,7 +2943,7 @@ div.favs div.posts {
filter: blur(100px); filter: blur(100px);
transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0);
z-index: 0; z-index: 0;
transition: 2s ease; transition: opacity 1.5s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 0.2; opacity: 0.2;
} }
@@ -2940,46 +2958,50 @@ button#togglebg {
0% { 0% {
opacity: 0; opacity: 0;
} }
100% {
100% {
opacity: 1; opacity: 1;
} }
} }
@keyframes fadeOutFX { @keyframes fadeOutFX {
0% { 0% {
opacity: 1; opacity: 1;
} }
100% {
100% {
opacity: 0; opacity: 0;
} }
} }
@keyframes fadeIn { @keyframes fadeIn {
0% { 0% {
opacity: 0; opacity: 0;
} }
100% { 100% {
opacity: 0.2; opacity: 0.2;
} }
} }
@keyframes fadeOut { @keyframes fadeOut {
0% { 0% {
opacity: 0.2; opacity: 0.2;
} }
100% {
opacity: 0; 100% {
} opacity: 0;
}
} }
.fader-in { .fader-in {
animation: fadeIn .8s steps(100) forwards; opacity: 0.4 !important;
} }
.fader-out { .fader-out {
animation: fadeOut .8s steps(100) forwards opacity: 0 !important;
} }
.settings { .settings {
display: grid; display: grid;
@@ -2995,7 +3017,7 @@ input[name="i_avatar"] {
input#s_avatar { input#s_avatar {
padding: 5px; padding: 5px;
border: 1px solid var(--black); border: 1px solid var(--black);
border-radius: 3px; border-radius: 0;
background-image: linear-gradient(to bottom, var(--nav-link-background-linear-gradient)); background-image: linear-gradient(to bottom, var(--nav-link-background-linear-gradient));
box-shadow: var(--nav-link-box-shadow); box-shadow: var(--nav-link-box-shadow);
cursor: pointer; cursor: pointer;
@@ -3004,6 +3026,7 @@ input#s_avatar {
#s_avatar:hover { #s_avatar:hover {
background: #ffffff0f; background: #ffffff0f;
} }
.theforceofthree { .theforceofthree {
display: grid; display: grid;
grid-template-columns: 0.4fr 1fr 0.4fr; grid-template-columns: 0.4fr 1fr 0.4fr;
@@ -3011,4 +3034,425 @@ input#s_avatar {
.upload { .upload {
padding: 10px; padding: 10px;
}
/* Infinite scroll loading indicator */
.loading-spinner {
display: inline-block;
font-size: 12px;
color: var(--footbar-color);
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 0.1;
}
50% {
opacity: 1;
}
}
/* Modern Tags Layout */
.tags-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
padding: 20px 0;
}
.tag-card {
display: flex;
flex-direction: column;
background: var(--badge-bg, #171717);
border-radius: 0;
overflow: hidden;
text-decoration: none !important;
transition: transform 0.2s, box-shadow 0.2s;
border: 1px solid var(--nav-border-color, rgba(255, 255, 255, 0.1));
position: relative;
}
.tag-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.4);
background: var(--dropdown-bg, #232323);
border-color: var(--accent, #9f0);
}
.tag-card-image {
width: 100%;
height: 100px;
overflow: hidden;
position: relative;
background: #000;
}
.tag-card-image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s;
opacity: 0.8;
}
.tag-card:hover .tag-card-image img {
transform: scale(1.1);
opacity: 1;
}
.tag-card-content {
padding: 15px;
display: flex;
flex-direction: column;
gap: 5px;
}
.tag-name {
color: var(--white, #fff);
font-weight: bold;
font-size: 1.1em;
font-family: var(--font, monospace);
}
.tag-count {
color: #888;
font-size: 0.9em;
}
/* Search Overlay */
#search-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(5px);
z-index: 10000;
display: none;
align-items: center;
justify-content: center;
padding: 20px;
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
#search-overlay.visible {
opacity: 1;
}
#search-input {
background: transparent;
border: none;
border-bottom: 2px solid var(--accent);
color: var(--white);
font-size: 3rem;
width: 100%;
max-width: 800px;
text-align: center;
outline: none;
font-family: var(--font);
padding: 10px;
}
#search-input::placeholder {
color: #555;
text-transform: uppercase;
}
#search-close {
position: absolute;
top: 20px;
right: 30px;
color: var(--white);
font-size: 2rem;
cursor: pointer;
font-family: sans-serif;
opacity: 0.7;
transition: opacity 0.2s;
}
#search-close:hover {
opacity: 1;
}
/* Delete Tag Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(5px);
z-index: 10001;
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background: var(--dropdown-bg);
border: 1px solid var(--nav-border-color);
padding: 30px;
border-radius: 0;
text-align: center;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
min-width: 300px;
}
.modal-content h3 {
margin-top: 0;
color: var(--white);
}
.modal-content p {
color: #ccc;
margin: 20px 0;
}
.modal-actions {
display: flex;
justify-content: center;
gap: 15px;
}
.modal-actions button {
padding: 10px 20px;
border: none;
border-radius: 0;
cursor: pointer;
font-weight: bold;
font-family: var(--font);
}
.btn-danger {
background: #e74c3c;
color: white;
}
.btn-danger:hover {
background: #c0392b;
}
.btn-secondary {
background: #555;
color: white;
}
.btn-secondary:hover {
background: #666;
}
/* Nav User Dropdown */
.nav-user-dropdown {
position: relative;
margin-left: 15px;
}
.nav-user-btn {
background: transparent;
border: 1px solid var(--nav-border-color);
color: var(--white);
padding: 6px 12px;
border-radius: 0;
cursor: pointer;
font-family: var(--font);
font-size: 14px;
transition: all 0.2s ease;
}
.nav-user-btn:hover {
background: rgba(255, 255, 255, 0.1);
border-color: var(--nav-border-color-hover);
}
.nav-user-menu {
display: none;
position: absolute;
top: calc(100% + 5px);
left: 0;
min-width: 150px;
background: var(--dropdown-bg);
border: 1px solid var(--nav-border-color);
border-radius: 0;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
z-index: 10000;
overflow: hidden;
}
.nav-user-menu.show {
display: block;
}
.nav-user-menu a {
display: block;
padding: 10px 15px;
color: var(--white);
text-decoration: none;
transition: background 0.2s;
}
.nav-user-menu a:hover {
background: rgba(255, 255, 255, 0.1);
}
.nav-user-divider {
height: 1px;
background: var(--nav-border-color);
margin: 5px 0;
}
/* Nav Left Group - Flexbox for dropdown + links */
.nav-left-group {
display: flex;
align-items: center;
gap: 15px;
flex-wrap: wrap;
}
.nav-links {
display: flex;
align-items: center;
gap: 12px;
}
.nav-links a {
color: var(--white);
text-decoration: none;
font-size: 14px;
opacity: 0.8;
transition: opacity 0.2s;
}
.nav-links a:hover {
opacity: 1;
}
.nav-links svg {
vertical-align: middle;
}
/* Mobile responsive navbar */
@media (max-width: 600px) {
.navbar {
flex-wrap: wrap;
gap: 10px;
}
.nav-left-group {
order: 2;
width: 100%;
justify-content: space-between;
}
.nav-links {
gap: 8px;
}
.nav-user-btn {
padding: 4px 8px;
font-size: 12px;
}
.nav-links a {
font-size: 12px;
}
}
/* Login Modal */
#login-modal,
#register-modal {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.85);
/* Semi-transparent black backdrop */
z-index: 99999;
display: flex;
/* Hidden by default via inline style usually, or JS toggles class */
justify-content: center;
align-items: center;
padding: 20px;
backdrop-filter: blur(5px);
}
.login-modal-content {
background: var(--bg);
border: 1px solid var(--accent);
border-radius: 8px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
max-width: 400px;
width: 100%;
position: relative;
text-align: center;
}
.login-modal-content .login-form {
display: flex;
flex-direction: column;
gap: 15px;
background: transparent;
/* Override default login-form bg if needed */
}
.login-modal-content .login-image {
max-width: 100%;
margin-bottom: 1rem;
border-radius: 4px;
}
.login-modal-content input[type="text"],
.login-modal-content input[type="password"] {
width: 100%;
padding: 10px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--nav-border-color);
color: var(--white);
font-family: var(--font);
}
.login-modal-content input:focus {
border-color: var(--accent);
outline: none;
}
.login-modal-content button[type="submit"] {
background: var(--accent);
color: var(--black);
border: none;
padding: 10px;
font-weight: bold;
cursor: pointer;
font-family: var(--font);
margin-top: 10px;
}
.login-modal-content button[type="submit"]:hover {
filter: brightness(1.1);
}
#login-modal-close,
#register-modal-close {
position: absolute;
top: 10px;
right: 15px;
background: none;
border: none;
color: var(--white);
font-size: 1.5rem;
cursor: pointer;
opacity: 0.7;
}
#login-modal-close:hover,
#register-modal-close:hover {
opacity: 1;
color: var(--accent);
} }

426
public/s/css/upload.css Normal file
View File

@@ -0,0 +1,426 @@
/* Upload Page Styles */
.upload-container {
max-width: 800px;
margin: 0 auto;
padding: 0 1rem;
}
.upload-container h2 {
margin-bottom: 1.5rem;
color: var(--accent);
text-align: center;
}
/* Guidelines */
.content-guidelines {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0;
margin-bottom: 2rem;
overflow: hidden;
}
.content-guidelines summary {
padding: 1rem 1.5rem;
cursor: pointer;
font-weight: 600;
color: var(--accent);
list-style: none;
/* Hide default triangle */
display: flex;
justify-content: space-between;
align-items: center;
}
.content-guidelines summary::-webkit-details-marker {
display: none;
}
.content-guidelines summary::after {
content: '+';
font-size: 1.2rem;
font-weight: bold;
}
.content-guidelines[open] summary::after {
content: '-';
}
.guidelines-content {
padding: 0 1.5rem 1.5rem 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.guidelines-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
margin-top: 1rem;
}
.guidelines-dont h5 {
color: #ff6b6b;
margin-bottom: 0.5rem;
}
.guidelines-do h5 {
color: #51cf66;
margin-bottom: 0.5rem;
}
.guidelines-grid ul {
list-style: none;
padding: 0;
margin: 0;
}
.guidelines-grid li {
padding: 0.3rem 0;
font-size: 0.9rem;
opacity: 0.8;
}
/* Upload Form */
.upload-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
background: rgba(255, 255, 255, 0.02);
padding: 2rem;
border-radius: 0;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.form-section label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.required {
color: #ff6b6b;
}
/* Drop Zone */
.drop-zone {
border: 2px dashed rgba(255, 255, 255, 0.2);
border-radius: 0;
padding: 2rem;
text-align: center;
cursor: pointer;
transition: all 0.2s;
position: relative;
min-height: 200px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.drop-zone:hover,
.drop-zone.dragover {
border-color: var(--accent);
background: rgba(255, 255, 255, 0.02);
}
.drop-zone input[type="file"] {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.drop-zone-prompt {
color: rgba(255, 255, 255, 0.5);
pointer-events: none;
/* Let input handle clicks */
}
/* File Preview (Stacked) */
.file-preview {
display: flex;
flex-direction: column;
/* Stacked */
align-items: center;
gap: 1rem;
width: 100%;
}
.file-preview video {
max-width: 100%;
max-height: 500px;
border-radius: 0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
outline: none;
margin-bottom: 1rem;
}
.file-meta-row {
display: flex;
align-items: center;
gap: 1rem;
width: 100%;
justify-content: center;
}
.file-info {
display: flex;
gap: 1rem;
align-items: center;
background: rgba(0, 0, 0, 0.3);
padding: 0.5rem 1rem;
border-radius: 0;
}
.file-name {
font-weight: 500;
}
.file-size {
opacity: 0.6;
font-size: 0.9rem;
}
.btn-remove {
background: #ff6b6b;
color: white;
border: none;
padding: 0.5rem 1.5rem;
border-radius: 0;
cursor: pointer;
font-weight: 600;
transition: background 0.2s;
/* remove margin-top as it's now in a flex row */
}
.btn-remove:hover {
background: #fa5252;
}
/* Ratings */
.rating-options {
display: flex;
gap: 1rem;
justify-content: center;
}
.rating-option input {
display: none;
}
.rating-label {
display: block;
padding: 0.75rem 2rem;
border-radius: 0;
border: 2px solid transparent;
transition: all 0.2s;
font-weight: 600;
text-align: center;
}
.rating-label.sfw {
background: rgba(81, 207, 102, 0.1);
border-color: rgba(81, 207, 102, 0.3);
color: #51cf66;
}
.rating-label.nsfw {
background: rgba(255, 107, 107, 0.1);
border-color: rgba(255, 107, 107, 0.3);
color: #ff6b6b;
}
.rating-option input:checked+.rating-label.sfw {
background: rgba(81, 207, 102, 0.2);
border-color: #51cf66;
}
.rating-option input:checked+.rating-label.nsfw {
background: rgba(255, 107, 107, 0.2);
border-color: #ff6b6b;
}
/* Tags */
.tag-input-container {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0;
padding: 0.5rem;
display: flex;
flex-wrap: wrap;
position: relative;
gap: 0.5rem;
}
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
background: var(--accent);
color: #000;
padding: 0.3rem 0.6rem;
border-radius: 0;
font-size: 0.9rem;
font-weight: 500;
}
.tag-chip button {
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: 0;
font-size: 1.1rem;
line-height: 1;
}
#tag-input {
flex: 1;
min-width: 120px;
background: transparent;
border: none;
color: inherit;
padding: 0.5rem;
outline: none;
}
.tag-count {
font-weight: normal;
font-size: 0.85rem;
opacity: 0.7;
}
.tag-count.valid {
color: #51cf66;
font-weight: bold;
opacity: 1;
}
.tag-suggestions {
/* (styles for dropdown remain similar, maybe cleaner shadow) */
position: absolute;
top: auto;
bottom: 100%;
left: 0;
right: 0;
background: #1e1e1e;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.5);
border-radius: 0;
max-height: 200px;
overflow-y: auto;
display: none;
z-index: 100;
}
.tag-suggestions.show {
display: block;
}
.tag-suggestion {
padding: 0.5rem 1rem;
cursor: pointer;
}
.tag-suggestion:hover,
.tag-suggestion.active {
background: rgba(255, 255, 255, 0.1);
}
/* Submit Button */
.btn-upload {
background: var(--accent);
color: #000;
border: none;
padding: 1rem 2rem;
border-radius: 0;
font-size: 1.1rem;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
width: 100%;
margin-top: 1rem;
}
.btn-upload:disabled {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.4);
cursor: not-allowed;
}
.btn-upload:not(:disabled):hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
}
/* Progress */
.upload-progress {
display: flex;
align-items: center;
gap: 1rem;
background: rgba(0, 0, 0, 0.2);
padding: 1rem;
border-radius: 0;
}
.progress-bar {
flex: 1;
height: 10px;
background: rgba(255, 255, 255, 0.1);
border-radius: 0;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent);
width: 0%;
transition: width 0.2s;
}
.progress-text {
font-weight: bold;
font-family: monospace;
}
.upload-status {
text-align: center;
padding: 1rem;
font-weight: 600;
}
.upload-status.error {
color: #ff6b6b;
}
.upload-status.success {
color: #51cf66;
}
/* Login Required */
.login-required {
text-align: center;
padding: 4rem 2rem;
border: 1px dashed rgba(255, 255, 255, 0.2);
border-radius: 0;
}
.btn-login {
display: inline-block;
margin-top: 1rem;
padding: 0.75rem 2rem;
background: var(--accent);
color: #000;
text-decoration: none;
border-radius: 0;
font-weight: 700;
}

View File

@@ -216,7 +216,7 @@ video {
} }
#main { #main {
padding: 25px; padding: 0px 25px 0px 25px;
} }
.container { .container {
@@ -262,7 +262,7 @@ video {
background: #0000008a !important; background: #0000008a !important;
} }
.pagination > a { .pagination>a {
background: #232323b2; background: #232323b2;
} }
@@ -285,32 +285,33 @@ div.search {
div.sbt { div.sbt {
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
align-content: center; align-content: center;
} }
#sbtButton { #sbtButton {
visibility: hidden; visibility: hidden;
} }
#sbtInput { #sbtInput {
background: #00000021; background: #00000021;
box-shadow: -1px -1px 0px #252525; box-shadow: -1px -1px 0px #252525;
border: inset 1px #0000001c; border: inset 1px #0000001c;
padding: revert; padding: revert;
box-shadow: inset 0px 0px 5px 1px #0000005e; box-shadow: inset 0px 0px 5px 1px #0000005e;
width: 100%; width: 100%;
} }
.navigation-links { .navigation-links {
display: grid; display: grid;
grid-row: 1; grid-row: 1;
grid-column: 2; grid-column: 2;
grid-template-columns: auto auto 1fr; grid-template-columns: auto auto 1fr;
} }
.navigation-links-guest, ol { .navigation-links-guest,
margin: 5px; ol {
margin-block-start: 0; margin: 5px;
margin-block-end: 0; margin-block-start: 0;
padding-inline-start: 0; margin-block-end: 0;
padding-inline-start: 0;
} }

View File

@@ -130,12 +130,12 @@
return false; return false;
} }
renderTags(res.tags); renderTags(res.tags);
span.parentElement.removeChild(span); if (span.parentElement) span.parentElement.removeChild(span);
testList.innerText = ""; testList.innerText = "";
addtagClick(); addtagClick();
} }
else if (e.key === "Escape") { else if (e.key === "Escape") {
span.parentElement.removeChild(span); if (span.parentElement) span.parentElement.removeChild(span);
testList.innerText = ""; testList.innerText = "";
} }
else { else {
@@ -184,13 +184,46 @@
if (!ctx) return; if (!ctx) return;
const { postid, poster } = ctx; const { postid, poster } = ctx;
if (!confirm(`Reason for deleting f0ckpost ${postid} by ${poster} (Weihnachten™)`)) const modal = document.getElementById('delete-item-modal');
return; const idEl = document.getElementById('delete-item-id');
const res = await post("/api/v2/admin/deletepost", { const posterEl = document.getElementById('delete-item-poster');
postid: postid const confirmBtn = document.getElementById('delete-item-confirm');
}); const cancelBtn = document.getElementById('delete-item-cancel');
if (!res.success) {
alert(res.msg); if (modal) {
idEl.textContent = postid;
posterEl.textContent = poster || 'unknown';
modal.style.display = 'flex';
const closeModal = () => {
modal.style.display = 'none';
confirmBtn.onclick = null;
cancelBtn.onclick = null;
};
cancelBtn.onclick = closeModal;
confirmBtn.onclick = async () => {
confirmBtn.textContent = 'Deleting...';
confirmBtn.disabled = true;
try {
const res = await post("/api/v2/admin/deletepost", {
postid: postid
});
if (!res.success) {
alert(res.msg);
confirmBtn.textContent = 'Delete';
confirmBtn.disabled = false;
} else {
closeModal();
window.location.href = '/random';
}
} catch (e) {
alert('Error: ' + e); // Or e.message
confirmBtn.textContent = 'Delete';
confirmBtn.disabled = false;
}
};
} }
}; };

View File

@@ -8,22 +8,176 @@ window.requestAnimFrame = (function () {
(() => { (() => {
let video; let video;
// User & Visitor dropdown toggle
const userToggle = document.getElementById('nav-user-toggle');
const userMenu = document.getElementById('nav-user-menu');
const visitorToggle = document.getElementById('nav-visitor-toggle');
const visitorMenu = document.getElementById('nav-visitor-menu');
if (userToggle && userMenu) {
userToggle.addEventListener('click', (e) => {
e.stopPropagation();
userMenu.classList.toggle('show');
});
}
if (visitorToggle && visitorMenu) {
visitorToggle.addEventListener('click', (e) => {
e.stopPropagation();
visitorMenu.classList.toggle('show');
});
}
document.addEventListener('click', (e) => {
if (userMenu && !userMenu.contains(e.target) && userToggle && !userToggle.contains(e.target)) {
userMenu.classList.remove('show');
}
if (visitorMenu && !visitorMenu.contains(e.target) && visitorToggle && !visitorToggle.contains(e.target)) {
visitorMenu.classList.remove('show');
}
});
// Login Modal Logic
const loginBtn = document.getElementById('nav-login-btn');
const loginModal = document.getElementById('login-modal');
const loginClose = document.getElementById('login-modal-close');
if (loginBtn && loginModal) {
loginBtn.addEventListener('click', (e) => {
e.preventDefault();
loginModal.style.display = 'flex';
// Close dropdown
if (visitorMenu) visitorMenu.classList.remove('show');
});
if (loginClose) {
loginClose.addEventListener('click', () => {
loginModal.style.display = 'none';
});
}
loginModal.addEventListener('click', (e) => {
if (e.target === loginModal) {
loginModal.style.display = 'none';
}
});
// Handle ESC key to close
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && loginModal.style.display === 'flex') {
loginModal.style.display = 'none';
}
});
// Handle Flash Message (login=success)
if (window.location.search.includes('login=success')) {
loginModal.style.display = 'flex';
const form = loginModal.querySelector('.login-form');
if (form && !form.querySelector('.flash-success')) {
const msg = document.createElement('div');
msg.className = 'flash-success';
msg.style.color = '#fff';
msg.style.background = 'var(--accent)'; // f0ck accent usually
msg.style.padding = '10px';
msg.style.borderRadius = '4px';
msg.style.marginBottom = '10px';
msg.style.textAlign = 'center';
msg.style.color = 'black'; // contrast
msg.style.fontWeight = 'bold';
msg.textContent = 'Success! You might login now.';
form.insertBefore(msg, form.firstChild); // Insert at top of form
// Clean URL
const url = new URL(window.location);
url.searchParams.delete('login');
window.history.replaceState({}, '', url);
}
}
}
// Register Modal Logic
const registerBtn = document.getElementById('nav-register-btn');
const registerModal = document.getElementById('register-modal');
const registerClose = document.getElementById('register-modal-close');
if (registerBtn && registerModal) {
registerBtn.addEventListener('click', (e) => {
e.preventDefault();
registerModal.style.display = 'flex';
// Close dropdown
if (visitorMenu) visitorMenu.classList.remove('show');
});
if (registerClose) {
registerClose.addEventListener('click', () => {
registerModal.style.display = 'none';
});
}
registerModal.addEventListener('click', (e) => {
if (e.target === registerModal) {
registerModal.style.display = 'none';
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && registerModal.style.display === 'flex') {
registerModal.style.display = 'none';
}
});
}
// Initialize background preference
if (localStorage.getItem('background') == undefined) {
localStorage.setItem('background', 'true');
}
var background = localStorage.getItem('background') === 'true';
// Apply initial visual state
var initialCanvas = document.getElementById('bg');
if (initialCanvas) {
if (background) {
initialCanvas.classList.add('fader-in');
initialCanvas.classList.remove('fader-out');
} else {
initialCanvas.classList.add('fader-out');
initialCanvas.classList.remove('fader-in');
}
}
if (elem = document.querySelector("#my-video")) { if (elem = document.querySelector("#my-video")) {
video = new v0ck(elem); video = new v0ck(elem);
/* Listener moved to global keybindings
document.addEventListener("keydown", e => { document.addEventListener("keydown", e => {
if (e.key === " " && e.target.tagName !== "INPUT" && e.target.tagName !== "TEXTAREA") { if (e.key === " " && e.target.tagName !== "INPUT" && e.target.tagName !== "TEXTAREA") {
video[video.paused ? 'play' : 'pause'](); video[video.paused ? 'play' : 'pause']();
document.querySelector('.v0ck_overlay').classList[video.paused ? 'remove' : 'add']('v0ck_hidden'); document.querySelector('.v0ck_overlay').classList[video.paused ? 'remove' : 'add']('v0ck_hidden');
} }
}); });
*/
const toggleBg = document.getElementById('togglebg');
if (toggleBg) {
toggleBg.addEventListener('click', function (e) { if (elem !== null) {
e.preventDefault(); // ... existing code ...
background = !background; }
localStorage.setItem('background', background.toString()); }
var canvas = document.getElementById('bg');
// Export init function for dynamic calls
window.initBackground = () => {
// Re-fetch elements as they might have been replaced
const elem = document.querySelector("#my-video");
const canvas = document.getElementById('bg');
if (elem) {
// Initialize video wrapper if needed or just get instance
// Assuming v0ck handles re-init or we just use raw element for events
// But video variable is local.
// We need to re-bind 'play' event if it's a new element.
if (canvas) {
// Restore visual state on re-init
if (background) { if (background) {
canvas.classList.add('fader-in'); canvas.classList.add('fader-in');
canvas.classList.remove('fader-out'); canvas.classList.remove('fader-out');
@@ -31,33 +185,102 @@ window.requestAnimFrame = (function () {
canvas.classList.add('fader-out'); canvas.classList.add('fader-out');
canvas.classList.remove('fader-in'); canvas.classList.remove('fader-in');
} }
animationLoop();
});
}
if (elem !== null) { const context = canvas.getContext('2d');
if (localStorage.getItem('background') == undefined) { const cw = canvas.width = canvas.clientWidth | 0;
localStorage.setItem('background', 'true'); const ch = canvas.height = canvas.clientHeight | 0;
}
var background = localStorage.getItem('background') === 'true'; const animationLoop = () => {
var canvas = document.getElementById('bg'); if (elem.paused || elem.ended || !background)
if (canvas) {
var context = canvas.getContext('2d');
var cw = canvas.width = canvas.clientWidth | 0;
var ch = canvas.height = canvas.clientHeight | 0;
function animationLoop() {
if (video.paused || video.ended || !background)
return; return;
context.drawImage(video, 0, 0, cw, ch); context.drawImage(elem, 0, 0, cw, ch);
window.requestAnimFrame(animationLoop); window.requestAnimFrame(animationLoop);
} }
elem.addEventListener('play', animationLoop); elem.addEventListener('play', animationLoop);
if (!elem.paused) {
animationLoop();
}
} }
} }
} };
// Initial call
window.initBackground();
const loadPageAjax = async (url) => {
// Show loading indicator
const navbar = document.querySelector("nav.navbar");
if (navbar) navbar.classList.add("pbwork");
try {
// Extract page number, user, tag, etc.
let page = 1;
const pMatch = url.match(/\/p\/(\d+)/);
if (pMatch) page = pMatch[1];
// Extract context
let tag = null, user = null, mime = null;
const tagMatch = url.match(/\/tag\/([^/]+)/);
if (tagMatch) tag = decodeURIComponent(tagMatch[1]);
const userMatch = url.match(/\/user\/([^/]+)/);
if (userMatch) user = decodeURIComponent(userMatch[1]);
const mimeMatch = url.match(/\/(image|audio|video)/);
if (mimeMatch) mime = mimeMatch[1];
let ajaxUrl = `/ajax/items/?page=${page}`;
if (tag) ajaxUrl += `&tag=${encodeURIComponent(tag)}`;
if (user) ajaxUrl += `&user=${encodeURIComponent(user)}`;
if (mime) ajaxUrl += `&mime=${encodeURIComponent(mime)}`;
console.log("Fetching Page:", ajaxUrl);
const response = await fetch(ajaxUrl, { credentials: 'include' });
const data = await response.json();
if (data.success) {
// Replace grid content
// If "infinite scroll" we might append, but pagination implies jumping properly?
// User said "resembled in pagination", which implies staying in sync.
// If I click Next Page, I expect to SEE page 2.
// But infinite scroll usually appends.
// Let's implement REPLACE for explicit page navigation to be safe/standard.
// Wait, the "infinite scroll" feature usually implies APPEND.
// If the user wants infinite scroll, they shouldn't click pagination?
// But if they scroll, `changePage` is called which clicks `.next`.
// So if I replace content, it breaks infinite scroll flow (items disappear).
// So I should APPEND if it's "next page" and we are already on the page?
// But `changePage` is triggered by scroll.
// Let's APPEND.
const posts = document.querySelector('.posts');
if (posts) {
// Check if we are appending (next page) or jumping
// For simple "infinite scroll", we append.
posts.insertAdjacentHTML('beforeend', data.html);
}
// Update pagination
if (data.pagination) {
document.querySelectorAll('.pagination-wrapper').forEach(el => el.innerHTML = data.pagination);
}
// Update History
history.pushState({}, '', url);
}
} catch (err) {
console.error(err);
window.location.href = url; // Fallback
} finally {
if (navbar) navbar.classList.remove("pbwork");
// Restore pagination visibility for Grid View
const navPag = document.querySelector('.pagination-container-fluid');
if (navPag) navPag.style.display = '';
}
};
let tt = false; let tt = false;
const stimeout = 500; const stimeout = 500;
@@ -68,31 +291,52 @@ window.requestAnimFrame = (function () {
} }
}; };
// Navbar scroll effect - make background black when scrolling
const navbar = document.querySelector('.navbar');
if (navbar) {
window.addEventListener('scroll', () => {
if (window.scrollY > 50) {
navbar.classList.add('scrolled');
} else {
navbar.classList.remove('scrolled');
}
});
}
const loadItemAjax = async (url, inheritContext = true) => { const loadItemAjax = async (url, inheritContext = true) => {
console.log("loadItemAjax called with:", url, "inheritContext:", inheritContext); console.log("loadItemAjax called with:", url, "inheritContext:", inheritContext);
// Show loading indicator // Show loading indicator
const navbar = document.querySelector("nav.navbar"); const navbar = document.querySelector("nav.navbar");
if (navbar) navbar.classList.add("pbwork"); if (navbar) navbar.classList.add("pbwork");
// Extract item ID from URL. Regex now handles query params, hashes, and trailing slashes. // Extract item ID from URL. Use the last numeric segment to avoid matching context IDs (like tag/1/...)
const match = url.match(/\/(\d+)(?:\/|#|\?|$)/); // Split path, filter numeric, pop last.
const pathSegments = new URL(url, window.location.origin).pathname.split('/');
const numericSegments = pathSegments.filter(s => /^\d+$/.test(s));
if (!match) { // Hide navbar pagination for Item View (matches SSR)
const navPag = document.querySelector('.pagination-container-fluid');
if (navPag) navPag.style.display = 'none';
if (numericSegments.length === 0) {
console.warn("loadItemAjax: No ID match found in URL", url); console.warn("loadItemAjax: No ID match found in URL", url);
// fallback for weird/external links // fallback for weird/external links
window.location.href = url; window.location.href = url;
return; return;
} }
const itemid = match[1]; const itemid = numericSegments.pop();
// <context-preservation> // <context-preservation>
// Extract context from Target URL first // Extract context from Target URL first
let tag = null, user = null; let tag = null, user = null, isFavs = false;
const tagMatch = url.match(/\/tag\/([^/]+)/); const tagMatch = url.match(/\/tag\/([^/]+)/);
if (tagMatch) tag = decodeURIComponent(tagMatch[1]); if (tagMatch) tag = decodeURIComponent(tagMatch[1]);
const userMatch = url.match(/\/user\/([^/]+)/); const userMatch = url.match(/\/user\/([^/]+)/);
if (userMatch) user = decodeURIComponent(userMatch[1]); // Note: "user" variable shadowed? No, block scope or different name? let user defined above. if (userMatch) {
user = decodeURIComponent(userMatch[1]);
if (url.match(/\/user\/[^/]+\/favs(\/|$|\?)/)) isFavs = true;
}
// If missing and inheritContext is true, check Window Location // If missing and inheritContext is true, check Window Location
if (inheritContext) { if (inheritContext) {
@@ -102,7 +346,11 @@ window.requestAnimFrame = (function () {
} }
if (!user) { if (!user) {
const wUserMatch = window.location.href.match(/\/user\/([^/]+)/); const wUserMatch = window.location.href.match(/\/user\/([^/]+)/);
if (wUserMatch) user = decodeURIComponent(wUserMatch[1]); if (wUserMatch) {
user = decodeURIComponent(wUserMatch[1]);
// Check for /favs (with or without trailing /, item id, or query params)
if (window.location.href.match(/\/user\/[^/]+\/favs(\/|$|\?)/)) isFavs = true;
}
} }
} }
// </context-preservation> // </context-preservation>
@@ -114,13 +362,14 @@ window.requestAnimFrame = (function () {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (tag) params.append('tag', tag); if (tag) params.append('tag', tag);
if (user) params.append('user', user); if (user) params.append('user', user);
if (isFavs) params.append('fav', 'true');
if ([...params].length > 0) { if ([...params].length > 0) {
ajaxUrl += '?' + params.toString(); ajaxUrl += '?' + params.toString();
} }
console.log("Fetching:", ajaxUrl); console.log("Fetching:", ajaxUrl);
const response = await fetch(ajaxUrl); const response = await fetch(ajaxUrl, { credentials: 'include' });
if (!response.ok) throw new Error(`Network response was not ok: ${response.status}`); if (!response.ok) throw new Error(`Network response was not ok: ${response.status}`);
const rawText = await response.text(); const rawText = await response.text();
@@ -140,21 +389,32 @@ window.requestAnimFrame = (function () {
html = rawText; html = rawText;
} }
let container = document.querySelector('.container'); let container = document.querySelector('#main .container');
if (!container && document.querySelector('.index-container')) { if (!container && document.querySelector('.index-container')) {
// Transition from Index to Item View // Transition from Index to Item View
const main = document.getElementById('main'); const main = document.getElementById('main');
main.innerHTML = '<div class="container"></div>'; main.innerHTML = '<div class="container"></div>';
container = main.querySelector('.container'); container = main.querySelector('.container');
} else if (!container && document.getElementById('main')) {
// Transition from User Profile or other pages without .container
const main = document.getElementById('main');
main.innerHTML = '<div class="container"></div>';
container = main.querySelector('.container');
} else if (container) { } else if (container) {
// Already in Item View, clear usage // Check if we are on Tags Overview logic (which reuses .container)
const oldContent = container.querySelector('.content'); const tagsOverview = container.querySelector('.tags');
const oldMetadata = container.querySelector('.metadata'); if (tagsOverview) {
const oldHeader = container.querySelector('._204863'); container.innerHTML = '';
if (oldHeader) oldHeader.remove(); } else {
if (oldContent) oldContent.remove(); // Already in Item View, clear usage
if (oldMetadata) oldMetadata.remove(); const oldContent = container.querySelector('.content');
const oldMetadata = container.querySelector('.metadata');
const oldHeader = container.querySelector('._204863');
if (oldHeader) oldHeader.remove();
if (oldContent) oldContent.remove();
if (oldMetadata) oldMetadata.remove();
}
} }
container.insertAdjacentHTML('beforeend', html); container.insertAdjacentHTML('beforeend', html);
@@ -169,13 +429,17 @@ window.requestAnimFrame = (function () {
// If we inherited context, we should reflect it in the URL // If we inherited context, we should reflect it in the URL
let pushUrl = `/${itemid}`; let pushUrl = `/${itemid}`;
// Logic from ajax.mjs context reconstruction: // Logic from ajax.mjs context reconstruction:
if (user) pushUrl = `/user/${user}/${itemid}`; // User takes precedence usually? Or strictly mutually exclusive in UI if (user) {
else if (tag) pushUrl = `/tag/${tag}/${itemid}`; pushUrl = `/user/${encodeURIComponent(user)}/${itemid}`;
if (isFavs) pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemid}`;
}
else if (tag) pushUrl = `/tag/${encodeURIComponent(tag)}/${itemid}`;
// We overwrite proper URL even if the link clicked was "naked" // We overwrite proper URL even if the link clicked was "naked"
history.pushState({}, '', pushUrl); history.pushState({}, '', pushUrl);
setupMedia(); setupMedia();
if (window.initBackground) window.initBackground();
// Try to extract ID from response if possible or just use itemid // Try to extract ID from response if possible or just use itemid
document.title = `f0bm - ${itemid}`; document.title = `f0bm - ${itemid}`;
if (navbar) navbar.classList.remove("pbwork"); if (navbar) navbar.classList.remove("pbwork");
@@ -187,13 +451,12 @@ window.requestAnimFrame = (function () {
}; };
const changePage = (e, pbwork = true) => { const changePage = (e, pbwork = true) => {
if (e.tagName === 'A') { if (pbwork) {
e.preventDefault(); const nav = document.querySelector("nav.navbar");
loadItemAjax(e.href); if (nav) nav.classList.add("pbwork");
} else {
pbwork && document.querySelector("nav.navbar").classList.add("pbwork");
!tt && (tt = setTimeout(() => e.click(), stimeout));
} }
// Trigger native click for navigation
e.click();
}; };
// Intercept clicks // Intercept clicks
@@ -208,10 +471,10 @@ window.requestAnimFrame = (function () {
return; return;
} }
const link = e.target.closest('#next, #prev, #random, .id-link, .nav-next, .nav-prev'); const link = e.target.closest('#next, #prev, #random, #nav-random, .id-link, .nav-next, .nav-prev');
if (link && link.href && link.hostname === window.location.hostname && !e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) { if (link && link.href && link.hostname === window.location.hostname && !e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) {
// Special check for random // Special check for random
if (link.id === 'random') { if (link.id === 'random' || link.id === 'nav-random') {
e.preventDefault(); e.preventDefault();
const nav = document.querySelector("nav.navbar"); const nav = document.querySelector("nav.navbar");
if (nav) nav.classList.add("pbwork"); if (nav) nav.classList.add("pbwork");
@@ -224,7 +487,12 @@ window.requestAnimFrame = (function () {
if (wTagMatch) params.append('tag', decodeURIComponent(wTagMatch[1])); if (wTagMatch) params.append('tag', decodeURIComponent(wTagMatch[1]));
const wUserMatch = window.location.href.match(/\/user\/([^/]+)/); const wUserMatch = window.location.href.match(/\/user\/([^/]+)/);
if (wUserMatch) params.append('user', decodeURIComponent(wUserMatch[1])); if (wUserMatch) {
params.append('user', decodeURIComponent(wUserMatch[1]));
if (window.location.href.match(/\/favs(\/|$|\?)/)) {
params.append('fav', 'true');
}
}
if ([...params].length > 0) { if ([...params].length > 0) {
randomUrl += '?' + params.toString(); randomUrl += '?' + params.toString();
@@ -240,18 +508,109 @@ window.requestAnimFrame = (function () {
window.location.href = link.href; window.location.href = link.href;
} }
}) })
.catch(() => window.location.href = link.href); .catch((err) => {
console.error("Random fetch failed:", err);
window.location.href = link.href;
});
return; return;
} }
// Standard item links // Standard item links
e.preventDefault(); e.preventDefault();
loadItemAjax(link.href, true); if (link.href.match(/\/p\/\d+/) || link.href.match(/[?&]page=\d+/)) {
loadPageAjax(link.href);
} else {
loadItemAjax(link.href, true);
}
} else if (e.target.closest('#togglebg')) {
e.preventDefault();
background = !background;
localStorage.setItem('background', background.toString());
var canvas = document.getElementById('bg');
if (canvas) {
if (background) {
canvas.classList.remove('fader-out');
canvas.classList.add('fader-in');
// Re-trigger loop if started completely fresh or paused
if (video && !video.paused) {
// We need to access animationLoop from closure?
// Accessing it via window.initBackground might be cleaner or just restart it.
// But initBackground defines it locally.
// We can just rely on initBackground being called or canvas update.
// Actually, if we just change opacity, the loop doesn't need to stop/start technically,
// but for performance we stopped it if !background.
// So we should restart it.
window.initBackground();
}
} else {
canvas.classList.remove('fader-in');
canvas.classList.add('fader-out');
}
}
} else if (e.target.closest('.removetag')) {
e.preventDefault();
const removeBtn = e.target.closest('.removetag');
const tagLink = removeBtn.previousElementSibling;
if (tagLink) {
const tagName = tagLink.textContent.trim();
const idLink = document.querySelector('.id-link');
const id = idLink ? idLink.textContent.trim() : null;
if (id && tagName) {
const modal = document.getElementById('delete-tag-modal');
const nameEl = document.getElementById('delete-tag-name');
const confirmBtn = document.getElementById('delete-tag-confirm');
const cancelBtn = document.getElementById('delete-tag-cancel');
if (modal) {
nameEl.textContent = tagName;
modal.style.display = 'flex';
const closeModal = () => {
modal.style.display = 'none';
confirmBtn.onclick = null;
cancelBtn.onclick = null;
};
cancelBtn.onclick = closeModal;
confirmBtn.onclick = () => {
confirmBtn.textContent = 'Deleting...';
confirmBtn.disabled = true;
fetch(`/api/v2/admin/${id}/tags/${encodeURIComponent(tagName)}`, {
method: 'DELETE'
})
.then(r => r.json())
.then(data => {
if (data.success) {
removeBtn.parentElement.remove();
closeModal();
} else {
alert('Error: ' + (data.msg || 'Unknown error'));
confirmBtn.textContent = 'Delete';
confirmBtn.disabled = false;
}
})
.catch(err => {
console.error(err);
alert('Failed to delete tag');
confirmBtn.textContent = 'Delete';
confirmBtn.disabled = false;
});
};
}
}
}
} }
}); });
window.addEventListener('popstate', (e) => { window.addEventListener('popstate', (e) => {
loadItemAjax(window.location.href, true); if (window.location.href.match(/\/p\/\d+/) || window.location.href.match(/[?&]page=\d+/) || window.location.pathname === '/') {
loadPageAjax(window.location.href);
} else {
loadItemAjax(window.location.href, true);
}
}); });
// <keybindings> // <keybindings>
@@ -261,8 +620,21 @@ window.requestAnimFrame = (function () {
"a": clickOnElementBinding("#next"), "a": clickOnElementBinding("#next"),
"ArrowRight": clickOnElementBinding("#prev"), "ArrowRight": clickOnElementBinding("#prev"),
"d": clickOnElementBinding("#prev"), "d": clickOnElementBinding("#prev"),
"r": clickOnElementBinding("#random"), "r": clickOnElementBinding("#random, #nav-random"),
" ": clickOnElementBinding("#f0ck-image") "l": () => {
const toggle = document.querySelector("#togglebg");
if (toggle) toggle.click();
},
" ": () => {
if (video && typeof video.play === 'function') { // Check if video wrapper exists/is valid
video[video.paused ? 'play' : 'pause']();
const overlay = document.querySelector('.v0ck_overlay');
if (overlay) overlay.classList[video.paused ? 'remove' : 'add']('v0ck_hidden');
} else {
const img = document.querySelector("#f0ck-image");
if (img) img.click();
}
}
}; };
document.addEventListener("keydown", e => { document.addEventListener("keydown", e => {
if (e.key in keybindings && e.target.tagName !== "INPUT" && e.target.tagName !== "TEXTAREA") { if (e.key in keybindings && e.target.tagName !== "INPUT" && e.target.tagName !== "TEXTAREA") {
@@ -327,124 +699,118 @@ window.requestAnimFrame = (function () {
} }
// </image-responsive> // </image-responsive>
// <scroller> // <infinite-scroll>
let tts = 0; const postsContainer = document.querySelector("div.posts");
const scroll_treshold = 1; if (postsContainer) {
if ([...document.querySelectorAll("div.posts")].length === 1) { // Infinite scroll state
document.addEventListener("wheel", e => { let infiniteState = {
loading: false,
hasMore: true,
currentPage: 1
};
// Extract current page from URL
const pageMatch = window.location.pathname.match(/\/p\/(\d+)/);
if (pageMatch) infiniteState.currentPage = parseInt(pageMatch[1]);
// Extract context (tag/user/mime) from URL
const getContext = () => {
const ctx = {};
const tagMatch = window.location.pathname.match(/\/tag\/([^/]+)/);
if (tagMatch) ctx.tag = decodeURIComponent(tagMatch[1]);
const userMatch = window.location.pathname.match(/\/user\/([^/]+)/);
if (userMatch) ctx.user = decodeURIComponent(userMatch[1]);
const mimeMatch = window.location.pathname.match(/\/(image|audio|video)(?:\/|$)/);
if (mimeMatch) ctx.mime = mimeMatch[1];
return ctx;
};
// Build URL path for history
const buildUrl = (page) => {
const ctx = getContext();
let path = '/';
if (ctx.tag) path += `tag/${ctx.tag}/`;
if (ctx.user) path += `user/${ctx.user}/`;
if (ctx.mime) path += `${ctx.mime}/`;
if (page > 1) path += `p/${page}`;
return path.replace(/\/$/, '') || '/';
};
// Fetch and append more items
const loadMoreItems = async () => {
if (infiniteState.loading || !infiniteState.hasMore) return;
infiniteState.loading = true;
const foot = document.querySelector("div#footbar");
if (foot) {
foot.innerHTML = '<span class="loading-spinner">Loading...</span>';
foot.style.color = 'var(--footbar-color)';
}
const nextPage = infiniteState.currentPage + 1;
const ctx = getContext();
const params = new URLSearchParams();
params.append('page', nextPage);
if (ctx.tag) params.append('tag', ctx.tag);
if (ctx.user) params.append('user', ctx.user);
if (ctx.mime) params.append('mime', ctx.mime);
try {
const response = await fetch(`/ajax/items?${params.toString()}`);
const data = await response.json();
if (data.success && data.html) {
// Append new thumbnails
postsContainer.insertAdjacentHTML('beforeend', data.html);
// Update state
infiniteState.currentPage = data.currentPage;
infiniteState.hasMore = data.hasMore;
// Update URL
history.replaceState({}, '', buildUrl(infiniteState.currentPage));
// Update pagination display if exists
const paginationLinks = document.querySelectorAll('.pagination .pagination-int-item, .pagination .btn');
paginationLinks.forEach(el => {
if (el.textContent.trim() == infiniteState.currentPage) {
el.classList.add('disabled');
}
});
} else {
infiniteState.hasMore = false;
}
} catch (err) {
console.error('Infinite scroll fetch error:', err);
} finally {
infiniteState.loading = false;
if (foot) {
foot.innerHTML = infiniteState.hasMore ? '&#9660;' : '&#8212;';
foot.style.color = 'transparent';
}
}
};
// Scroll detection - preload before reaching bottom
const PRELOAD_OFFSET = 500; // pixels before bottom to trigger load
window.addEventListener("scroll", () => {
if (!document.querySelector('#main')) return; if (!document.querySelector('#main')) return;
if (Math.ceil(window.innerHeight + window.scrollY) >= document.querySelector('#main').offsetHeight && e.deltaY > 0) { // down const scrollPosition = window.innerHeight + window.scrollY;
if (elem = document.querySelector(".pagination > .next:not(.disabled)")) { const pageHeight = document.querySelector('#main').offsetHeight;
if (tts < scroll_treshold) { const distanceFromBottom = pageHeight - scrollPosition;
const foot = document.querySelector("div#footbar");
if (foot) { // Load more when within PRELOAD_OFFSET pixels of bottom
foot.style.boxShadow = "inset 0px 4px 0px var(--footbar-color)"; if (distanceFromBottom < PRELOAD_OFFSET && infiniteState.hasMore && !infiniteState.loading) {
foot.style.color = "var(--footbar-color)"; loadMoreItems();
}
tts++;
}
else
changePage(elem);
}
}
else if (window.scrollY <= 0 && e.deltaY < 0) { // up
if (elem = document.querySelector(".pagination > .prev:not(.disabled)")) {
if (tts < scroll_treshold) {
const nav = document.querySelector("nav.navbar");
if (nav) {
nav.style.boxShadow = "0px 2px 0px var(--loading-indicator-color)";
nav.style.transition = ".2s ease-in-out";
}
tts++;
}
else
changePage(elem);
}
}
else {
tts = 0;
const foot = document.querySelector("div#footbar");
if (foot) {
foot.style.boxShadow = "unset";
foot.style.color = "transparent";
}
const nav = document.querySelector("nav.navbar");
if (nav) nav.style.boxShadow = "unset";
} }
}); });
} }
// </infinite-scroll>
const rmatch = /\/p\/(\d+?)/;
if (document.referrer.match(rmatch) && document.location.href.match(rmatch))
if (document.location.href.match(rmatch) < document.referrer.match(rmatch))
document.body.scrollTop = document.body.scrollHeight;
// </scroller>
// <swipe>
const swipeRT = {
xDown: null,
yDown: null,
xDiff: null,
yDiff: null,
timeDown: null,
startEl: null
};
const swipeOpt = {
treshold: 20, // 20px
timeout: 500 // 500ms
};
document.addEventListener('touchstart', e => {
swipeRT.startEl = e.target;
swipeRT.timeDown = Date.now();
swipeRT.xDown = e.touches[0].clientX;
swipeRT.yDown = e.touches[0].clientY;
swipeRT.xDiff = 0;
swipeRT.yDiff = 0;
}, false);
document.addEventListener('touchmove', e => {
if (!swipeRT.xDown || !swipeRT.yDown)
return;
swipeRT.xDiff = swipeRT.xDown - e.touches[0].clientX;
swipeRT.yDiff = swipeRT.yDown - e.touches[0].clientY;
}, false);
document.addEventListener('touchend', e => {
if (swipeRT.startEl !== e.target)
return;
const timeDiff = Date.now() - swipeRT.timeDown;
let elem;
if (Math.abs(swipeRT.xDiff) > Math.abs(swipeRT.yDiff)) {
if (Math.abs(swipeRT.xDiff) > swipeOpt.treshold && timeDiff < swipeOpt.timeout) {
if (swipeRT.xDiff > 0) // left
elem = document.querySelector(".pagination > .next:not(.disabled)");
else // right
elem = document.querySelector(".pagination > .prev:not(.disabled)");
}
}
else {
if (Math.abs(swipeRT.yDiff) > swipeOpt.treshold && timeDiff < swipeOpt.timeout) {
if (navbar = document.querySelector("nav.navbar") && document.querySelector("div.posts")) {
if (swipeRT.yDiff > 0 && (window.innerHeight + window.scrollY) >= document.body.offsetHeight) // up
elem = document.querySelector(".pagination > .next:not(.disabled)");
else if (swipeRT.yDiff <= 0 && window.scrollY <= 0 && document.querySelector("div.posts")) // down
elem = document.querySelector(".pagination > .prev:not(.disabled)");
}
}
}
swipeRT.xDown = null;
swipeRT.yDown = null;
swipeRT.timeDown = null;
if (elem)
changePage(elem);
}, false);
// </swipe>
// <visualizer> // <visualizer>
if (audioElement = document.querySelector("audio")) { if (audioElement = document.querySelector("audio")) {
@@ -513,9 +879,77 @@ window.requestAnimFrame = (function () {
// <scroller> // <scroller>
// <search-overlay>
const initSearch = () => {
if (!document.getElementById('search-overlay')) {
const overlay = document.createElement('div');
overlay.id = 'search-overlay';
overlay.innerHTML = `
<div id="search-close">&times;</div>
<input type="text" id="search-input" placeholder="Search Tags..." autocomplete="off">
`;
document.body.appendChild(overlay);
const input = document.getElementById('search-input');
const close = document.getElementById('search-close');
const btns = document.querySelectorAll('#nav-search-btn, #nav-search-btn-guest');
const toggleSearch = (show) => {
if (show) {
overlay.style.display = 'flex';
// Force reflow
overlay.offsetHeight;
overlay.classList.add('visible');
input.focus();
} else {
overlay.classList.remove('visible');
setTimeout(() => {
overlay.style.display = 'none';
}, 200);
}
};
btns.forEach(btn => btn.addEventListener('click', (e) => {
e.preventDefault();
toggleSearch(true);
}));
close.addEventListener('click', () => toggleSearch(false));
// Close on click outside (background)
overlay.addEventListener('click', (e) => {
if (e.target === overlay) toggleSearch(false);
});
// ESC to close
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && overlay.classList.contains('visible')) {
toggleSearch(false);
}
// "k" to open
if (e.key === 'k' && e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA' && !overlay.classList.contains('visible')) {
e.preventDefault();
toggleSearch(true);
}
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const val = input.value.trim();
if (val) {
window.location.href = `/tag/${encodeURIComponent(val)}`;
}
}
});
}
};
initSearch();
// </search-overlay>
// </scroller> // </scroller>
})(); })();
// disable default scroll event when mouse is on content div // disable default scroll event when mouse is on content div
// this is useful for items that have a lot of tags for example: 12536 // this is useful for items that have a lot of tags for example: 12536
const targetSelector = '.content'; const targetSelector = '.content';
@@ -541,10 +975,13 @@ function init() {
window.addEventListener('load', init); window.addEventListener('load', init);
document.getElementById('sbtForm').addEventListener('submit', (e) => { const sbtForm = document.getElementById('sbtForm');
e.preventDefault(); if (sbtForm) {
const input = document.getElementById('sbtInput').value.trim(); sbtForm.addEventListener('submit', (e) => {
if (input) { e.preventDefault();
window.location.href = `/tag/${encodeURIComponent(input)}`; const input = document.getElementById('sbtInput').value.trim();
} if (input) {
}); window.location.href = `/tag/${encodeURIComponent(input)}`;
}
});
}

View File

@@ -1,10 +1,10 @@
const Cookie = { const Cookie = {
get: name => { get: name => {
const c = document.cookie.match(`(?:(?:^|.*; *)${name} *= *([^;]*).*$)|^.*$`)[1]; const c = document.cookie.match(`(?:(?:^|.*; *)${name} *= *([^;]*).*$)|^.*$`)[1];
if(c) return decodeURIComponent(c); if (c) return decodeURIComponent(c);
}, },
set: (name, value, opts = {}) => { set: (name, value, opts = {}) => {
if(opts.days) { if (opts.days) {
opts['max-age'] = opts.days * 60 * 60 * 24; opts['max-age'] = opts.days * 60 * 60 * 24;
delete opts.days; delete opts.days;
} }
@@ -17,39 +17,42 @@ const Cookie = {
(() => { (() => {
const acttheme = Cookie.get('theme') ?? "w0bm"; const acttheme = Cookie.get('theme') ?? "w0bm";
const themecontainer = document.querySelector("li#themes > ul.dropdown-menu"); const themecontainer = document.querySelector("li#themes > ul.dropdown-menu");
if (!themecontainer) return; // Theme menu not present on this page
const themes = [...themecontainer.querySelectorAll("li > a")].map(t => t.innerText.toLowerCase()); const themes = [...themecontainer.querySelectorAll("li > a")].map(t => t.innerText.toLowerCase());
if(acttheme !== document.documentElement.getAttribute("theme") && themes.includes(acttheme)) if (acttheme !== document.documentElement.getAttribute("theme") && themes.includes(acttheme))
document.documentElement.setAttribute("theme", acttheme); document.documentElement.setAttribute("theme", acttheme);
[...themecontainer.querySelectorAll("li > a")].forEach(t => t.addEventListener("click", e => { // [...themecontainer.querySelectorAll("li > a")].forEach(t => t.addEventListener("click", e => {
e.preventDefault(); // e.preventDefault();
const _theme = e.target.innerText.toLowerCase(); // const _theme = e.target.innerText.toLowerCase();
document.documentElement.setAttribute("theme", _theme); // document.documentElement.setAttribute("theme", _theme);
document.querySelector("#themes > a").setAttribute("content", _theme); // document.querySelector("#themes > a").setAttribute("content", _theme);
Cookie.set("theme", _theme, { path: "/", days: 360 }); // Cookie.set("theme", _theme, { path: "/", days: 360 });
return false; // return false;
})); // }));
document.addEventListener("keydown", e => { document.addEventListener("keydown", e => {
if(e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")
return; return;
const acttheme = Cookie.get('theme') ?? "w0bm"; const acttheme = Cookie.get('theme') ?? "w0bm";
const themes = [...themecontainer.querySelectorAll("li > a")].map(t => t.innerText.toLowerCase()); const themes = [...themecontainer.querySelectorAll("li > a")].map(t => t.innerText.toLowerCase());
const k = e.key; const k = e.key;
if(k === "t") { // if (k === "t") {
e.preventDefault(); // e.preventDefault();
let i = themes.indexOf(acttheme); // let i = themes.indexOf(acttheme);
if(++i >= themes.length) // if (++i >= themes.length)
i = 0; // i = 0;
document.documentElement.setAttribute("theme", themes[i]); // document.documentElement.setAttribute("theme", themes[i]);
document.querySelector("#themes > a").setAttribute("content", themes[i]); // document.querySelector("#themes > a").setAttribute("content", themes[i]);
Cookie.set("theme", themes[i], { path: "/", days: 360 }); // Cookie.set("theme", themes[i], { path: "/", days: 360 });
} // }
}); });
if(tbuttonfull = document.querySelector('svg#a_tfull')) { if (tbuttonfull = document.querySelector('svg#a_tfull')) {
tbuttonfull.addEventListener('click', e => { tbuttonfull.addEventListener('click', e => {
let f = Cookie.get('fullscreen'); let f = Cookie.get('fullscreen');
if(f == 1) { if (f == 1) {
Cookie.set('fullscreen', 0); Cookie.set('fullscreen', 0);
document.querySelector('html').setAttribute('res', ''); document.querySelector('html').setAttribute('res', '');
tbuttonfull.innerHTML = `<use href="/s/img/iconset.svg#window-maximize"></use>`; tbuttonfull.innerHTML = `<use href="/s/img/iconset.svg#window-maximize"></use>`;

351
public/s/js/upload.js Normal file
View File

@@ -0,0 +1,351 @@
(() => {
const form = document.getElementById('upload-form');
if (!form) return;
const fileInput = document.getElementById('file-input');
const dropZone = document.getElementById('drop-zone');
const filePreview = document.getElementById('file-preview');
// Note: prompt is now a label, but accessible via class
const dropZonePrompt = dropZone.querySelector('.drop-zone-prompt');
const fileName = document.getElementById('file-name');
const fileSize = document.getElementById('file-size');
const removeFile = document.getElementById('remove-file');
const tagInput = document.getElementById('tag-input');
const tagsList = document.getElementById('tags-list');
const tagsHidden = document.getElementById('tags-hidden');
const tagCount = document.getElementById('tag-count');
const tagSuggestions = document.getElementById('tag-suggestions');
const submitBtn = document.getElementById('submit-btn');
const progressContainer = document.getElementById('upload-progress');
const progressFill = document.getElementById('progress-fill');
const progressText = document.getElementById('progress-text');
const statusDiv = document.getElementById('upload-status');
let tags = [];
let selectedFile = null;
const formatSize = (bytes) => {
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
while (bytes >= 1024 && i < units.length - 1) {
bytes /= 1024;
i++;
}
return bytes.toFixed(2) + ' ' + units[i];
};
const updateSubmitButton = () => {
const rating = document.querySelector('input[name="rating"]:checked');
const hasFile = selectedFile !== null;
const hasRating = rating !== null;
const hasTags = tags.length >= 3;
submitBtn.disabled = !(hasFile && hasRating && hasTags);
if (!hasTags) {
submitBtn.querySelector('.btn-text').textContent = (3 - tags.length) + ' more tag' + (3 - tags.length !== 1 ? 's' : '') + ' required';
} else if (!hasFile) {
submitBtn.querySelector('.btn-text').textContent = 'Upload (Select file first)';
} else if (!hasRating) {
submitBtn.querySelector('.btn-text').textContent = 'Select SFW or NSFW';
} else {
submitBtn.querySelector('.btn-text').textContent = 'Upload';
}
tagCount.textContent = '(' + tags.length + '/3 minimum)';
tagCount.classList.toggle('valid', tags.length >= 3);
};
const handleFile = (file) => {
if (!file) return;
const validTypes = ['video/mp4', 'video/webm'];
// Check extensions as fallback
const ext = file.name.split('.').pop().toLowerCase();
const validExts = ['mp4', 'webm'];
if (!validTypes.includes(file.type) && !validExts.includes(ext)) {
statusDiv.textContent = 'Only mp4 and webm files are allowed';
statusDiv.className = 'upload-status error';
return;
}
selectedFile = file;
fileName.textContent = file.name;
fileSize.textContent = formatSize(file.size);
dropZonePrompt.style.display = 'none';
// Hide input so it doesn't intercept clicks on preview/remove button
fileInput.style.display = 'none';
filePreview.style.display = 'flex';
statusDiv.textContent = '';
statusDiv.className = 'upload-status';
// Video Preview
const itemPreview = filePreview.querySelector('.item-preview') || document.createElement('div');
itemPreview.className = 'item-preview';
itemPreview.style.marginRight = '15px';
// Clear previous
const existingVid = filePreview.querySelector('video');
if (existingVid) existingVid.remove();
const vid = document.createElement('video');
vid.src = URL.createObjectURL(file);
vid.controls = true; // User might want to scrub to check if it's the right video
vid.autoplay = true;
vid.muted = true;
vid.loop = true;
// Styles handled by CSS now for "Big" preview
filePreview.prepend(vid);
updateSubmitButton();
};
const preventDefaults = (e) => {
e.preventDefault();
e.stopPropagation();
};
// Attach drag events only to dropZone now (Input is hidden)
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, preventDefaults, false);
});
['dragenter', 'dragover'].forEach(eventName => {
dropZone.addEventListener(eventName, () => dropZone.classList.add('dragover'), false);
});
['dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, () => dropZone.classList.remove('dragover'), false);
});
dropZone.addEventListener('drop', (e) => {
const dt = e.dataTransfer;
const files = dt.files;
handleFile(files[0]);
});
// Native change listener on hidden input
fileInput.addEventListener('change', (e) => handleFile(e.target.files[0]));
removeFile.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
selectedFile = null;
fileInput.value = '';
dropZonePrompt.style.display = 'block';
fileInput.style.display = 'block'; // Restore input visibility
filePreview.style.display = 'none';
// Clear preview video
const vid = filePreview.querySelector('video');
if (vid) vid.remove();
updateSubmitButton();
});
const addTag = (tagName) => {
tagName = tagName.trim().toLowerCase();
if (!tagName || tags.includes(tagName)) return;
if (tagName === 'sfw' || tagName === 'nsfw') return;
tags.push(tagName);
const chip = document.createElement('span');
chip.className = 'tag-chip';
chip.innerHTML = tagName + '<button type="button">&times;</button>';
chip.querySelector('button').addEventListener('click', () => {
tags = tags.filter(t => t !== tagName);
chip.remove();
updateSubmitButton();
});
tagsList.appendChild(chip);
tagsHidden.value = tags.join(',');
tagInput.value = '';
tagSuggestions.innerHTML = '';
tagSuggestions.classList.remove('show');
updateSubmitButton();
};
let currentFocus = -1;
const addActive = (x) => {
if (!x) return false;
removeActive(x);
if (currentFocus >= x.length) currentFocus = 0;
if (currentFocus < 0) currentFocus = (x.length - 1);
x[currentFocus].classList.add("active");
// Scroll to view
x[currentFocus].scrollIntoView({ block: 'nearest' });
};
const removeActive = (x) => {
for (let i = 0; i < x.length; i++) {
x[i].classList.remove("active");
}
};
tagInput.addEventListener('keydown', (e) => {
const x = tagSuggestions.getElementsByClassName("tag-suggestion");
if (e.key === 'ArrowDown') {
currentFocus++;
addActive(x);
} else if (e.key === 'ArrowUp') {
currentFocus--;
addActive(x);
} else if (e.key === 'Enter') {
e.preventDefault();
if (currentFocus > -1) {
if (x) x[currentFocus].click();
} else {
addTag(tagInput.value);
}
} else if (e.key === 'Escape') {
tagSuggestions.classList.remove('show');
currentFocus = -1;
}
});
let debounceTimer;
tagInput.addEventListener('input', () => {
clearTimeout(debounceTimer);
const query = tagInput.value.trim();
currentFocus = -1; // Reset focus on new input
if (query.length < 2) {
tagSuggestions.classList.remove('show');
return;
}
debounceTimer = setTimeout(async () => {
try {
const res = await fetch('/api/v2/admin/tags/suggest?q=' + encodeURIComponent(query));
const data = await res.json();
if (data.success && data.suggestions && data.suggestions.length > 0) {
const filtered = data.suggestions.filter(s => !tags.includes(s.tag.toLowerCase()));
let html = '';
for (let i = 0; i < Math.min(8, filtered.length); i++) {
html += '<div class="tag-suggestion">' + filtered[i].tag + '</div>';
}
tagSuggestions.innerHTML = html;
tagSuggestions.classList.add('show');
tagSuggestions.querySelectorAll('.tag-suggestion').forEach(el => {
el.addEventListener('click', () => {
addTag(el.textContent);
tagInput.focus();
});
});
} else {
tagSuggestions.classList.remove('show');
}
} catch (err) {
console.error(err);
}
}, 200);
});
document.addEventListener('click', (e) => {
if (!tagInput.contains(e.target) && !tagSuggestions.contains(e.target)) {
tagSuggestions.classList.remove('show');
}
});
document.querySelectorAll('input[name="rating"]').forEach(radio => {
radio.addEventListener('change', updateSubmitButton);
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (!selectedFile || tags.length < 3) return;
const rating = document.querySelector('input[name="rating"]:checked');
if (!rating) return;
submitBtn.disabled = true;
submitBtn.querySelector('.btn-text').style.display = 'none';
submitBtn.querySelector('.btn-loading').style.display = 'inline';
progressContainer.style.display = 'flex';
statusDiv.textContent = '';
statusDiv.className = 'upload-status';
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('rating', rating.value);
formData.append('tags', tags.join(','));
try {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
progressFill.style.width = percent + '%';
progressText.textContent = percent + '%';
}
});
xhr.onload = () => {
const res = JSON.parse(xhr.responseText);
if (res.success) {
statusDiv.innerHTML = '✓ ' + res.msg;
statusDiv.className = 'upload-status success';
form.reset();
tags = [];
tagsList.innerHTML = '';
selectedFile = null;
dropZonePrompt.style.display = 'block'; // label is actually flex/block via CSS
filePreview.style.display = 'none';
const vid = filePreview.querySelector('video');
if (vid) vid.remove();
} else {
statusDiv.textContent = '✕ ' + res.msg;
statusDiv.className = 'upload-status error';
if (res.repost) {
statusDiv.innerHTML += ' <a href="/' + res.repost + '">View existing</a>';
}
}
submitBtn.querySelector('.btn-text').style.display = 'inline';
submitBtn.querySelector('.btn-loading').style.display = 'none';
progressContainer.style.display = 'none';
progressFill.style.width = '0%';
updateSubmitButton();
};
xhr.onerror = () => {
statusDiv.textContent = '✕ Upload failed. Please try again.';
statusDiv.className = 'upload-status error';
submitBtn.querySelector('.btn-text').style.display = 'inline';
submitBtn.querySelector('.btn-loading').style.display = 'none';
progressContainer.style.display = 'none';
updateSubmitButton();
};
xhr.open('POST', '/api/v2/upload');
xhr.send(formData);
} catch (err) {
console.error(err);
statusDiv.textContent = '✕ Upload failed: ' + err.message;
statusDiv.className = 'upload-status error';
submitBtn.querySelector('.btn-text').style.display = 'inline';
submitBtn.querySelector('.btn-loading').style.display = 'none';
updateSubmitButton();
}
});
updateSubmitButton();
})();

View File

@@ -8,13 +8,13 @@ const globalfilter = cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ');
export default { export default {
getf0cks: async (o = { user, tag, mime, page, mode, fav, session, limit }) => { getf0cks: async (o = { user, tag, mime, page, mode, fav, session, limit }) => {
const user = o.user ? decodeURI(o.user) : null; const user = o.user ? decodeURI(o.user) : null;
const tag = lib.parseTag(o.tag ?? null); const tag = lib.parseTag(o.tag ?? null);
const mime = o.mime ?? null; const mime = o.mime ?? null;
const page = +(o.page ?? 1); const page = +(o.page ?? 1);
const smime = cfg.allowedMimes.includes(mime) ? mime + "/%" : mime === "" ? "%" : "%"; const smime = cfg.allowedMimes.includes(mime) ? mime + "/%" : mime === "" ? "%" : "%";
const eps = o.limit ?? cfg.websrv.eps; const eps = o.limit ?? cfg.websrv.eps;
const tmp = { user, tag, mime, smime, page, mode: o.mode }; const tmp = { user, tag, mime, smime, page, mode: o.mode };
const modequery = mime == "audio" ? lib.getMode(0) : lib.getMode(o.mode ?? 0); const modequery = mime == "audio" ? lib.getMode(0) : lib.getMode(o.mode ?? 0);
@@ -27,17 +27,17 @@ export default {
left join favorites on favorites.item_id = items.id left join favorites on favorites.item_id = items.id
left join "user" on "user".id = favorites.user_id left join "user" on "user".id = favorites.user_id
where where
${ db.unsafe(modequery) } ${db.unsafe(modequery)}
and items.active = 'true' and items.active = 'true'
${ tag ? db`and tags.normalized ilike '%' || slugify(${tag}) || '%'` : db`` } ${tag ? db`and tags.normalized ilike '%' || slugify(${tag}) || '%'` : db``}
${ o.fav ? db`and "user".user ilike ${'%'+user+'%'}` : db`` } ${o.fav ? db`and "user".user ilike ${'%' + user + '%'}` : db``}
${ !o.fav && user ? db`and items.username ilike ${'%'+user+'%'}` : db`` } ${!o.fav && user ? db`and items.username ilike ${'%' + user + '%'}` : db``}
${ mime ? db`and items.mime ilike ${smime}` : db`` } ${mime ? db`and items.mime ilike ${smime}` : db``}
${ !o.session && globalfilter ? db`and items.id not in (select item_id from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db`` } ${!o.session && globalfilter ? db`and items.id not in (select item_id from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``}
group by items.id, tags.tag group by items.id, tags.tag
`)?.length || 0; `)?.length || 0;
if(!total || total === 0) { if (!total || total === 0) {
return { return {
success: false, success: false,
message: "404 - no f0cks given" message: "404 - no f0cks given"
@@ -61,13 +61,13 @@ export default {
left join "user" on "user".id = favorites.user_id left join "user" on "user".id = favorites.user_id
left join tags_assign ta on ta.item_id = items.id and (ta.tag_id = 1 or ta.tag_id = 2) left join tags_assign ta on ta.item_id = items.id and (ta.tag_id = 1 or ta.tag_id = 2)
where where
${ db.unsafe(modequery) } ${db.unsafe(modequery)}
and items.active = 'true' and items.active = 'true'
${ tag ? db`and tags.normalized ilike '%' || slugify(${tag}) || '%'` : db`` } ${tag ? db`and tags.normalized ilike '%' || slugify(${tag}) || '%'` : db``}
${ o.fav ? db`and "user".user ilike ${'%'+user+'%'}` : db`` } ${o.fav ? db`and "user".user ilike ${'%' + user + '%'}` : db``}
${ !o.fav && user ? db`and items.username ilike ${'%'+user+'%'}` : db`` } ${!o.fav && user ? db`and items.username ilike ${'%' + user + '%'}` : db``}
${ mime ? db`and items.mime ilike ${smime}` : db`` } ${mime ? db`and items.mime ilike ${smime}` : db``}
${ !o.session && globalfilter ? db`and items.id not in (select item_id from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db`` } ${!o.session && globalfilter ? db`and items.id not in (select item_id from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``}
group by items.id, tags.tag, ta.tag_id group by items.id, tags.tag, ta.tag_id
order by items.id desc order by items.id desc
offset ${offset} offset ${offset}
@@ -75,11 +75,11 @@ export default {
`; `;
const cheat = []; const cheat = [];
for(let i = Math.max(1, act_page - 3); i <= Math.min(act_page + 3, pages); i++) for (let i = Math.max(1, act_page - 3); i <= Math.min(act_page + 3, pages); i++)
cheat.push(i); cheat.push(i);
const link = lib.genLink({ user, tag, mime, type: o.fav ? 'favs' : 'f0cks', path: 'p/' }); const link = lib.genLink({ user, tag, mime, type: o.fav ? 'favs' : 'f0cks', path: 'p/' });
return { return {
success: true, success: true,
items: rows, items: rows,
@@ -96,54 +96,61 @@ export default {
}; };
}, },
getf0ck: async (o = ({ user, tag, mime, itemid, mode, session })) => { getf0ck: async (o = ({ user, tag, mime, itemid, mode, session })) => {
const user = o.user ? decodeURI(o.user) : null; const user = o.user ? decodeURI(o.user) : null;
const tag = lib.parseTag(o.tag ?? null); const tag = lib.parseTag(o.tag ?? null);
const mime = (o.mime ?? ""); const mime = (o.mime ?? "");
const itemid = +(o.itemid ?? 404); const itemid = +(o.itemid ?? 404);
const smime = cfg.allowedMimes.includes(mime) ? mime + "/%" : mime === "" ? "%" : "%"; const smime = cfg.allowedMimes.includes(mime) ? mime + "/%" : mime === "" ? "%" : "%";
const tmp = { user, tag, mime, smime, itemid }; const tmp = { user, tag, mime, smime, itemid };
const modequery = mime == "audio" ? lib.getMode(0) : lib.getMode(o.mode ?? 0); const modequery = mime == "audio" ? lib.getMode(0) : lib.getMode(o.mode ?? 0);
if(itemid === 404) { if (itemid === 404) {
return { return {
success: false, success: false,
message: "404 - f0ck not found" message: "404 - f0ck not found"
}; };
} }
const items = await db` const items = await db`
select distinct on (items.id) select distinct on (items.id)
items.* items.*
from items from items
left join tags_assign on tags_assign.item_id = items.id left join tags_assign on tags_assign.item_id = items.id
left join tags on tags.id = tags_assign.tag_id left join tags on tags.id = tags_assign.tag_id
left join favorites on favorites.item_id = items.id ${o.fav
left join "user" on "user".id = favorites.user_id ? db`inner join favorites on favorites.item_id = items.id inner join "user" on "user".id = favorites.user_id`
: db`left join favorites on favorites.item_id = items.id left join "user" on "user".id = favorites.user_id`
}
left join tags_assign ta on ta.item_id = items.id and (ta.tag_id = 1 or ta.tag_id = 2) left join tags_assign ta on ta.item_id = items.id and (ta.tag_id = 1 or ta.tag_id = 2)
where where
${ db.unsafe(modequery) } ${db.unsafe(modequery)}
and items.active = 'true' and items.active = 'true'
${ tag ? db`and tags.normalized ilike '%' || slugify(${tag}) || '%'` : db`` } ${tag ? db`and tags.normalized ilike '%' || slugify(${tag}) || '%'` : db``}
${ o.fav ? db`and "user".user ilike ${'%'+user+'%'}` : db`` } ${o.fav ? db`and "user"."user" ilike ${user}` : db``}
${ !o.fav && user ? db`and items.username ilike ${'%'+user+'%'}` : db`` } ${!o.fav && user ? db`and items.username ilike ${'%' + user + '%'}` : db``}
${ mime ? db`and items.mime ilike ${smime}` : db`` } ${mime ? db`and items.mime ilike ${smime}` : db``}
${ !o.session && globalfilter ? db`and items.id not in (select item_id from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db`` } ${!o.session && globalfilter ? db`and items.id not in (select item_id from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``}
group by items.id, tags.tag, ta.tag_id group by items.id, tags.tag, ta.tag_id
order by items.id desc order by items.id desc
`; `;
console.log('[GETF0CK DEBUG] Query params:', { user, itemid, fav: o.fav });
console.log('[GETF0CK DEBUG] Items found:', items.length, 'Item IDs:', items.slice(0, 10).map(i => i.id));
const item = items.findIndex(i => i.id === itemid); const item = items.findIndex(i => i.id === itemid);
const actitem = items[item]; const actitem = items[item];
if(!actitem) { // sfw-check! console.log('[GETF0CK DEBUG] findIndex result:', item, 'actitem exists:', !!actitem);
if (!actitem) { // sfw-check!
return { return {
success: false, success: false,
message: "Sorry, this post is currently not visible." message: "Sorry, this post is currently not visible."
}; };
} }
const tags = await lib.getTags(itemid); const tags = await lib.getTags(itemid);
const cheat = [...new Set(items.slice(Math.max(0, item - 3), item + 4).map(i => i.id))]; const cheat = [...new Set(items.slice(Math.max(0, item - 3), item + 4).map(i => i.id))];
const link = lib.genLink({ user, tag, mime, type: o.fav ? 'favs' : 'f0cks', path: '' }); const link = lib.genLink({ user, tag, mime, type: o.fav ? 'favs' : 'f0cks', path: '' });
@@ -154,14 +161,14 @@ export default {
left join "user_options" on "user_options".user_id = "favorites".user_id left join "user_options" on "user_options".user_id = "favorites".user_id
where "favorites".item_id = ${itemid} where "favorites".item_id = ${itemid}
`; `;
let coverart = true; let coverart = true;
try { try {
await fs.promises.access(`./public${cfg.websrv.paths.coverarts}/${actitem.id}.webp`); await fs.promises.access(`./public${cfg.websrv.paths.coverarts}/${actitem.id}.webp`);
} catch(err) { } catch (err) {
coverart = false; coverart = false;
} }
const data = { const data = {
success: true, success: true,
user: { user: {
@@ -201,16 +208,16 @@ export default {
tmp tmp
}; };
return data; return data;
},getRandom: async (o = ({ user, tag, mime, mode, fav, session })) => { }, getRandom: async (o = ({ user, tag, mime, mode, fav, session })) => {
const user = o.user ? decodeURI(o.user) : null; const user = o.user ? decodeURI(o.user) : null;
const tag = lib.parseTag(o.tag ?? null); const tag = lib.parseTag(o.tag ?? null);
const mime = (o.mime ?? ""); const mime = (o.mime ?? "");
const smime = cfg.allowedMimes.includes(mime) ? mime + "/%" : mime === "" ? "%" : "%"; const smime = cfg.allowedMimes.includes(mime) ? mime + "/%" : mime === "" ? "%" : "%";
const modequery = mime == "audio" ? lib.getMode(0) : lib.getMode(o.mode ?? 0); const modequery = mime == "audio" ? lib.getMode(0) : lib.getMode(o.mode ?? 0);
let item; let item;
if (o.fav && user) { if (o.fav && user) {
// Special case: random from user's favorites // Special case: random from user's favorites
item = await db` item = await db`
@@ -219,10 +226,15 @@ export default {
from favorites from favorites
inner join items on favorites.item_id = items.id inner join items on favorites.item_id = items.id
inner join "user" on "user".id = favorites.user_id inner join "user" on "user".id = favorites.user_id
left join tags_assign on tags_assign.item_id = items.id
left join tags on tags.id = tags_assign.tag_id
where where
"user".user ilike ${'%' + user + '%'} ${db.unsafe(modequery)}
and "user".user ilike ${'%' + user + '%'}
and items.active = 'true' and items.active = 'true'
${mime ? db`and items.mime ilike ${smime}` : db``} ${mime ? db`and items.mime ilike ${smime}` : db``}
${!o.session && globalfilter ? db`and items.id not in (select item_id from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``}
group by items.id
order by random() order by random()
limit 1 limit 1
`; `;
@@ -246,20 +258,20 @@ export default {
limit 1 limit 1
`; `;
} }
if (item.length === 0) { if (item.length === 0) {
return { return {
success: false, success: false,
message: "no f0cks found :(" message: "no f0cks found :("
}; };
} }
const link = lib.genLink({ user, tag, mime, type: o.fav ? 'favs' : 'f0cks' }); const link = lib.genLink({ user, tag, mime, type: o.fav ? 'favs' : 'f0cks' });
return { return {
success: true, success: true,
link: link, link: link,
itemid: item[0].id itemid: item[0].id
}; };
} }
}; };

View File

@@ -2,10 +2,11 @@ import db from "../sql.mjs";
import lib from "../lib.mjs"; import lib from "../lib.mjs";
import { exec } from "child_process"; import { exec } from "child_process";
import { promises as fs } from "fs"; import { promises as fs } from "fs";
import cfg from "../config.mjs";
export default (router, tpl) => { export default (router, tpl) => {
router.get(/^\/login(\/)?$/, async (req, res) => { router.get(/^\/login(\/)?$/, async (req, res) => {
if(req.cookies.session) { if (req.cookies.session) {
return res.reply({ return res.reply({
body: tpl.render('error', { body: tpl.render('error', {
message: "you're already logged in lol", message: "you're already logged in lol",
@@ -17,7 +18,7 @@ export default (router, tpl) => {
body: tpl.render("login", { theme: req.cookies.theme ?? "f0ck" }) body: tpl.render("login", { theme: req.cookies.theme ?? "f0ck" })
}); });
}); });
router.post(/^\/login(\/)?$/, async (req, res) => { router.post(/^\/login(\/)?$/, async (req, res) => {
const user = await db` const user = await db`
select * select *
@@ -25,9 +26,9 @@ export default (router, tpl) => {
where "login" = ${req.post.username.toLowerCase()} where "login" = ${req.post.username.toLowerCase()}
limit 1 limit 1
`; `;
if(user.length === 0) if (user.length === 0)
return res.reply({ body: "user doesn't exist or wrong password" }); return res.reply({ body: "user doesn't exist or wrong password" });
if(!(await lib.verify(req.post.password, user[0].password))) if (!(await lib.verify(req.post.password, user[0].password)))
return res.reply({ body: "user doesn't exist or wrong password" }); return res.reply({ body: "user doesn't exist or wrong password" });
const stamp = ~~(Date.now() / 1e3); const stamp = ~~(Date.now() / 1e3);
@@ -36,7 +37,7 @@ export default (router, tpl) => {
where last_action <= ${(Date.now() - 6048e5)} where last_action <= ${(Date.now() - 6048e5)}
and kmsi = 0 and kmsi = 0
`; `;
const session = lib.md5(lib.createID()); const session = lib.md5(lib.createID());
const blah = { const blah = {
user_id: user[0].id, user_id: user[0].id,
@@ -49,8 +50,7 @@ export default (router, tpl) => {
}; };
await db` await db`
insert into "user_sessions" ${ insert into "user_sessions" ${db(blah, 'user_id', 'session', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi')
db(blah, 'user_id', 'session', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi')
} }
`; `;
@@ -60,16 +60,16 @@ export default (router, tpl) => {
"Location": "/" "Location": "/"
}).end(); }).end();
}); });
router.get(/^\/logout$/, lib.loggedin, async (req, res) => { router.get(/^\/logout$/, lib.loggedin, async (req, res) => {
const usersession = await db` const usersession = await db`
select * select *
from "user_sessions" from "user_sessions"
where id = ${+req.session.sess_id} where id = ${+req.session.sess_id}
`; `;
if(usersession.length === 0) if (usersession.length === 0)
return res.reply({ body: "nope 2" }); return res.reply({ body: "nope 2" });
await db` await db`
delete from "user_sessions" delete from "user_sessions"
where id = ${+req.session.sess_id} where id = ${+req.session.sess_id}
@@ -80,7 +80,7 @@ export default (router, tpl) => {
"Location": "/" "Location": "/"
}).end(); }).end();
}); });
router.get(/^\/login\/pwdgen$/, async (req, res) => { router.get(/^\/login\/pwdgen$/, async (req, res) => {
res.reply({ res.reply({
body: "<form action=\"/login/pwdgen\" method=\"post\"><input type=\"text\" name=\"pwd\" placeholder=\"pwd\" /><input type=\"submit\" value=\"f0ck it\" /></form>" body: "<form action=\"/login/pwdgen\" method=\"post\"><input type=\"text\" name=\"pwd\" placeholder=\"pwd\" /><input type=\"submit\" value=\"f0ck it\" /></form>"
@@ -102,7 +102,7 @@ export default (router, tpl) => {
}, req) }, req)
}); });
}); });
router.get(/^\/admin\/sessions(\/)?$/, lib.auth, async (req, res) => { router.get(/^\/admin\/sessions(\/)?$/, lib.auth, async (req, res) => {
const rows = await db` const rows = await db`
select "user_sessions".*, "user".user select "user_sessions".*, "user".user
@@ -110,7 +110,7 @@ export default (router, tpl) => {
left join "user" on "user".id = "user_sessions".user_id left join "user" on "user".id = "user_sessions".user_id
order by "user_sessions".last_used desc order by "user_sessions".last_used desc
`; `;
res.reply({ res.reply({
body: tpl.render("admin/sessions", { body: tpl.render("admin/sessions", {
session: req.session, session: req.session,
@@ -121,79 +121,223 @@ export default (router, tpl) => {
}); });
}); });
// router.get(/^\/admin\/log(\/)?$/, lib.auth, async (req, res) => { router.get(/^\/admin\/approve\/?/, lib.auth, async (req, res) => {
// // Funktioniert ohne systemd service natürlich nicht. if (req.url.qs?.id) {
// exec("journalctl -qeu f0ck --no-pager", (err, stdout) => { const id = +req.url.qs.id;
// res.reply({ const f0ck = await db`
// body: tpl.render("admin/log", { select dest, mime
// log: stdout.split("\n").slice(0, -1), from "items"
// tmp: null where
// }, req) id = ${id} and
// }); active = 'false'
// }); limit 1
// }); `;
if (f0ck.length === 0) {
return res.reply({
body: `f0ck ${id}: f0ck not found`
});
}
// router.get(/^\/admin\/recover\/?/, lib.auth, async (req, res) => { await db`update "items" set active = 'true' where id = ${id}`;
// Gelöschte Objekte werden nicht aufgehoben.
// if(req.url.qs?.id) {
// const id = +req.url.qs.id;
// const f0ck = await db`
// select dest, mime
// from "items"
// where
// id = ${id} and
// active = 'false'
// limit 1
// `;
// if(f0ck.length === 0) {
// return res.reply({
// body: `f0ck ${id}: f0ck not found`
// });
// }
// await db`update "items" set active = 'true' where id = ${id}`; // Check if files need moving (if they are in deleted/)
try {
await fs.access(`./public/b/${f0ck[0].dest}`);
// Exists in public, good (new upload)
} catch {
// Not in public, likely a deleted item being recovered
await fs.copyFile(`./deleted/b/${f0ck[0].dest}`, `./public/b/${f0ck[0].dest}`).catch(_ => { });
await fs.copyFile(`./deleted/t/${id}.webp`, `./public/t/${id}.webp`).catch(_ => { });
await fs.unlink(`./deleted/b/${f0ck[0].dest}`).catch(_ => { });
await fs.unlink(`./deleted/t/${id}.webp`).catch(_ => { });
// await fs.copyFile(`./deleted/b/${f0ck[0].dest}`, `./public/b/${f0ck[0].dest}`).catch(_=>{}); if (f0ck[0].mime.startsWith('audio')) {
// await fs.copyFile(`./deleted/t/${id}.webp`, `./public/t/${id}.webp`).catch(_=>{}); await fs.copyFile(`./deleted/ca/${id}.webp`, `./public/ca/${id}.webp`).catch(_ => { });
// await fs.unlink(`./deleted/b/${f0ck[0].dest}`).catch(_=>{}); await fs.unlink(`./deleted/ca/${id}.webp`).catch(_ => { });
// await fs.unlink(`./deleted/t/${id}.webp`).catch(_=>{}); }
}
// if(f0ck[0].mime.startsWith('audio')) { return res.writeHead(302, {
// await fs.copyFile(`./deleted/ca/${id}.webp`, `./public/ca/${id}.webp`).catch(_=>{}); "Location": `/${id}`
// await fs.unlink(`./deleted/ca/${id}.webp`).catch(_=>{}); }).end();
// } }
// return res.reply({ const page = +req.url.qs.page || 1;
// body: `f0ck ${id} recovered. <a href="/admin/recover">back</a>` const limit = 50;
// }); const offset = (page - 1) * limit;
// }
// const _posts = await db` const total = (await db`select count(*) as c from "items" where active = 'false'`)[0].c;
// select id, mime, username const pages = Math.ceil(total / limit);
// from "items"
// where
// active = 'false'
// order by id desc
// `;
// if(_posts.length === 0) { const _posts = await db`
// return res.reply({ select id, mime, username, dest
// body: 'blah' from "items"
// }); where
// } active = 'false'
order by id desc
limit ${limit} offset ${offset}
`;
// const posts = await Promise.all(_posts.map(async p => ({ if (_posts.length === 0 && page > 1) {
// ...p, // if page empty, maybe redirect to last page or page 1?
// thumbnail: (await fs.readFile(`./deleted/t/${p.id}.webp`)).toString('base64') // Just render empty for now
// }))); }
// res.reply({ if (_posts.length === 0) {
// body: tpl.render('admin/recover', { return res.reply({
// posts, body: tpl.render('admin/approve', { posts: [], pages: 0, page: 1, tmp: null }, req)
// tmp: null });
// }, req) }
// });
// }); const posts = await Promise.all(_posts.map(async p => {
// Try to get thumbnail from public or deleted
let thumb;
try {
// Try public first
thumb = (await fs.readFile(`./public/t/${p.id}.webp`)).toString('base64');
} catch {
try {
thumb = (await fs.readFile(`./deleted/t/${p.id}.webp`)).toString('base64');
} catch {
thumb = ""; // No thumbnail?
}
}
return {
...p,
thumbnail: thumb
};
}));
res.reply({
body: tpl.render('admin/approve', {
posts,
page,
pages,
stats: { total: posts.length },
tmp: null
}, req)
});
});
const deleteItem = async (id) => {
const f0ck = await db`
select dest, mime
from "items"
where
id = ${id}
limit 1
`;
if (f0ck.length > 0) {
console.log(`[ADMIN DENY] Found item, deleting files: ${f0ck[0].dest}`);
// Delete files
await fs.unlink(`./public/b/${f0ck[0].dest}`).catch(e => console.log('File error pub/b:', e.message));
await fs.unlink(`./public/t/${id}.webp`).catch(e => console.log('File error pub/t:', e.message));
await fs.unlink(`./deleted/b/${f0ck[0].dest}`).catch(e => console.log('File error del/b:', e.message));
await fs.unlink(`./deleted/t/${id}.webp`).catch(e => console.log('File error del/t:', e.message));
if (f0ck[0].mime.startsWith('audio')) {
await fs.unlink(`./public/ca/${id}.webp`).catch(() => { });
await fs.unlink(`./deleted/ca/${id}.webp`).catch(() => { });
}
// Delete DB entries
console.log('[ADMIN DENY] Deleting DB entries...');
try {
await db`delete from "tags_assign" where item_id = ${id}`;
await db`delete from "favorites" where item_id = ${id}`;
await db`delete from "comments" where item_id = ${id}`.catch(() => { });
await db`delete from "items" where id = ${id}`;
console.log('[ADMIN DENY] Deleted successfully');
return true;
} catch (dbErr) {
console.error('[ADMIN DENY DB ERROR]', dbErr);
return false;
}
} else {
console.log('[ADMIN DENY] Item not found in DB');
return false;
}
};
router.get(/^\/admin\/deny\/?/, lib.auth, async (req, res) => {
console.log('[ADMIN DENY] Logs initiated');
if (req.url.qs?.id) {
const id = +req.url.qs.id;
console.log(`[ADMIN DENY] Denying ID: ${id}`);
await deleteItem(id);
return res.writeHead(302, {
"Location": `/admin/approve`
}).end();
}
console.log('[ADMIN DENY] No ID provided');
return res.writeHead(302, { "Location": "/admin/approve" }).end();
});
router.post(/^\/admin\/deny-multi\/?/, lib.auth, async (req, res) => {
try {
const ids = req.post.ids;
if (!Array.isArray(ids)) throw new Error('ids must be an array');
console.log(`[ADMIN DENY MULTI] Denying ${ids.length} items`);
for (const id of ids) {
await deleteItem(+id);
}
return res.reply({ success: true });
} catch (err) {
console.error('[ADMIN DENY MULTI ERROR]', err);
return res.reply({ success: false, msg: err.message }, 500);
}
});
// Token Routes
router.get(/^\/admin\/tokens\/?$/, lib.auth, async (req, res) => {
res.reply({
body: tpl.render("admin/tokens", { session: req.session, tmp: null }, req)
});
});
router.get(/^\/api\/v2\/admin\/tokens\/?$/, lib.auth, async (req, res) => {
const tokens = await db`
select invite_tokens.*, "user".user as used_by_name
from invite_tokens
left join "user" on "user".id = invite_tokens.used_by
order by created_at desc
`;
if (res.json) {
return res.json({ success: true, tokens });
}
// Fallback if res.json is not available
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, tokens }));
});
router.post(/^\/api\/v2\/admin\/tokens\/create\/?$/, lib.auth, async (req, res) => {
try {
const secret = cfg.main.invite_secret || 'defaultsecret';
const token = lib.md5(lib.createID() + secret).substring(0, 10).toUpperCase(); // Short readable token
await db`
insert into invite_tokens (token, created_at, created_by)
values (${token}, ${~~(Date.now() / 1e3)}, ${req.session.id})
`;
if (res.json) return res.json({ success: true, token });
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, token }));
} catch (err) {
if (res.json) return res.json({ success: false, msg: err.message });
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message }));
}
});
router.post(/^\/api\/v2\/admin\/tokens\/delete\/?$/, lib.auth, async (req, res) => {
if (!req.post.id) {
if (res.json) return res.json({ success: false });
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false }));
}
await db`delete from invite_tokens where id = ${req.post.id}`;
if (res.json) return res.json({ success: true });
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true }));
});
return router; return router;
}; };

View File

@@ -14,7 +14,13 @@ export default (router, tpl) => {
let contextUrl = `/${req.params.itemid}`; let contextUrl = `/${req.params.itemid}`;
if (query.tag) contextUrl = `/tag/${query.tag}/${req.params.itemid}`; if (query.tag) contextUrl = `/tag/${query.tag}/${req.params.itemid}`;
if (query.user) contextUrl = `/user/${query.user}/${req.params.itemid}`; // User filter takes precedence if both? usually mutually exclusive if (query.user) {
contextUrl = query.fav === 'true'
? `/user/${query.user}/favs/${req.params.itemid}`
: `/user/${query.user}/${req.params.itemid}`;
}
console.log('[AJAX DEBUG] Params:', { itemid: req.params.itemid, user: query.user, fav: query.fav, contextUrl });
const data = await f0cklib.getf0ck({ const data = await f0cklib.getf0ck({
itemid: req.params.itemid, itemid: req.params.itemid,
@@ -23,9 +29,12 @@ export default (router, tpl) => {
url: contextUrl, url: contextUrl,
user: query.user, user: query.user,
tag: query.tag, tag: query.tag,
mime: query.mime mime: query.mime,
fav: query.fav === 'true'
}); });
console.log('[AJAX DEBUG] getf0ck result:', { success: data.success, message: data.message });
if (!data.success) { if (!data.success) {
return res.reply({ return res.reply({
code: 404, code: 404,
@@ -38,10 +47,8 @@ export default (router, tpl) => {
if (req.session) { if (req.session) {
data.session = { ...req.session }; data.session = { ...req.session };
// data.user comes from f0cklib (uploader). req.session.user is logged-in user string. // data.user comes from f0cklib (uploader). req.session.user is logged-in user string.
// If template engine confuses them, removing session.user from this context might help. // Templates use session.user for matching favorites. We must preserve it.
// item-partial doesn't use session.user. // if (data.session.user) delete data.session.user; // REMOVED THIS
// Note: If anything fails, it prints literal code, so we ensure no collision.
if (data.session.user) delete data.session.user;
} else { } else {
data.session = false; data.session = false;
} }
@@ -49,6 +56,7 @@ export default (router, tpl) => {
// Inject missing variables normally provided by req or middleware // Inject missing variables normally provided by req or middleware
data.url = { pathname: `/${req.params.itemid}` }; // Template expects url.pathname data.url = { pathname: `/${req.params.itemid}` }; // Template expects url.pathname
data.fullscreen = req.cookies.fullscreen || 0; // Index.mjs uses req.cookies.fullscreen data.fullscreen = req.cookies.fullscreen || 0; // Index.mjs uses req.cookies.fullscreen
data.hidePagination = true;
// Render both the item content and the pagination // Render both the item content and the pagination
const itemHtml = tpl.render('ajax-item', data); const itemHtml = tpl.render('ajax-item', data);
@@ -64,5 +72,65 @@ export default (router, tpl) => {
}); });
}); });
// Infinite scroll endpoint for index thumbnails
router.get(/\/ajax\/items/, async (req, res) => {
let query = {};
if (typeof req.url === 'string') {
const parsedUrl = url.parse(req.url, true);
query = parsedUrl.query;
} else {
query = req.url.qs || {};
}
const page = parseInt(query.page) || 1;
const data = await f0cklib.getf0cks({
page: page,
tag: query.tag || null,
user: query.user || null,
mime: query.mime || null,
mode: req.session.mode,
session: !!req.session,
fav: false
});
if (!data.success) {
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
success: false,
html: '',
hasMore: false
})
});
}
// Render just the thumbnail items
const itemsHtml = tpl.render('snippets/items-grid', {
items: data.items,
link: data.link
});
// Render pagination
const paginationHtml = tpl.render('snippets/pagination', {
pagination: data.pagination,
link: data.link
});
const hasMore = data.pagination.next !== null;
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
success: true,
html: itemsHtml,
pagination: paginationHtml,
hasMore: hasMore,
nextPage: data.pagination.next,
currentPage: data.pagination.page
})
});
});
return router; return router;
}; };

View File

@@ -1,9 +1,12 @@
import { promises as fs } from "fs"; import { promises as fs } from "fs";
import db from '../../sql.mjs'; import db from '../../sql.mjs';
import lib from '../../lib.mjs'; import lib from '../../lib.mjs';
import cfg from '../../config.mjs';
import search from '../../routeinc/search.mjs'; import search from '../../routeinc/search.mjs';
const allowedMimes = [ "audio", "image", "video", "%" ]; const allowedMimes = ["audio", "image", "video", "%"];
const globalfilter = cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ') : null;
export default router => { export default router => {
router.group(/^\/api\/v2/, group => { router.group(/^\/api\/v2/, group => {
group.get(/$/, (req, res) => { group.get(/$/, (req, res) => {
@@ -11,26 +14,43 @@ export default router => {
}); });
group.get(/\/random(\/user\/.+|\/image|\/video|\/audio)?$/, async (req, res) => { group.get(/\/random(\/user\/.+|\/image|\/video|\/audio)?$/, async (req, res) => {
const user = req.url.split[3] === "user" ? req.url.split[4] : "%"; const pathUser = req.url.split[3] === "user" ? req.url.split[4] : null;
const mime = (allowedMimes.filter(n => req.url.split[3]?.startsWith(n))[0] ? req.url.split[3] : "") + "%"; const user = req.url.qs.user || pathUser || "%";
const pathMime = allowedMimes.filter(n => req.url.split[3]?.startsWith(n))[0] ? req.url.split[3] : "";
const mime = (req.url.qs.mime || pathMime) + "%";
const tag = req.url.qs.tag || null;
const isFav = req.url.qs.fav === 'true';
const hasSession = !!req.session;
const modequery = mime.startsWith("audio") ? lib.getMode(0) : lib.getMode(req.session?.mode ?? 0);
const rows = await db` const rows = await db`
select * select "items".*
from "items" from "items"
${isFav
? db`join "favorites" on "favorites".item_id = "items".id join "user" as fu on fu.id = "favorites".user_id`
: db``
}
left join tags_assign on tags_assign.item_id = items.id
left join tags on tags.id = tags_assign.tag_id
where where
${db.unsafe(modequery)} and
mime ilike ${mime} and mime ilike ${mime} and
username ilike ${user} and
active = 'true' active = 'true'
${isFav ? db`and fu."user" = ${user}` : db`and items.username ilike ${user}`}
${tag ? db`and tags.normalized ilike '%' || slugify(${tag}) || '%'` : db``}
${!hasSession && globalfilter ? db`and items.id not in (select item_id from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``}
order by random() order by random()
limit 1 limit 1
`; `;
return res.json({ return res.json({
success: rows.length > 0, success: rows.length > 0,
items: rows.length > 0 ? rows[0] : [] items: rows.length > 0 ? rows[0] : []
}); });
}); });
group.get(/\/items\/get/, async (req, res) => { group.get(/\/items\/get/, async (req, res) => {
let eps = 150; let eps = 150;
@@ -51,17 +71,15 @@ export default router => {
where where
${db.unsafe(modequery)} and ${db.unsafe(modequery)} and
active = 'true' active = 'true'
${ ${opt.older
opt.older ? db`and id <= ${opt.older}`
? db`and id <= ${opt.older}` : opt.newer
: opt.newer ? db`and id >= ${opt.newer}`
? db`and id >= ${opt.newer}` : db``
: db`` }
} order by id ${opt.newer
order by id ${ ? db`asc`
opt.newer : db`desc`
? db`asc`
: db`desc`
} }
limit ${eps} limit ${eps}
`).sort((a, b) => b.id - a.id); `).sort((a, b) => b.id - a.id);
@@ -73,10 +91,10 @@ export default router => {
items: rows items: rows
}, 200); }, 200);
}); });
group.get(/\/item\/[0-9]+$/, async (req, res) => { group.get(/\/item\/[0-9]+$/, async (req, res) => {
const id = +req.url.split[3]; const id = +req.url.split[3];
const item = await db` const item = await db`
select * select *
from "items" from "items"
@@ -97,14 +115,14 @@ export default router => {
order by id desc order by id desc
limit 1 limit 1
`; `;
if(item.length === 0) { if (item.length === 0) {
return res.json({ return res.json({
success: false, success: false,
msg: 'no items found' msg: 'no items found'
}); });
} }
const rows = { const rows = {
...item[0], ...item[0],
...{ ...{
@@ -118,11 +136,11 @@ export default router => {
rows rows
}); });
}); });
group.get(/\/user\/.*(\/\d+)?$/, async (req, res) => { group.get(/\/user\/.*(\/\d+)?$/, async (req, res) => {
const user = req.url.split[3]; const user = req.url.split[3];
const eps = +req.url.split[4] || 50; const eps = +req.url.split[4] || 50;
const rows = db` const rows = db`
select id, mime, size, src, stamp, userchannel, username, usernetwork select id, mime, size, src, stamp, userchannel, username, usernetwork
from "items" from "items"
@@ -130,7 +148,7 @@ export default router => {
order by stamp desc order by stamp desc
limit ${+eps} limit ${+eps}
`; `;
return res.json({ return res.json({
success: rows.length > 0, success: rows.length > 0,
items: rows.length > 0 ? rows : [] items: rows.length > 0 ? rows : []
@@ -140,7 +158,7 @@ export default router => {
// tags lol // tags lol
group.put(/\/admin\/tags\/(?<tagname>.*)/, lib.loggedin, async (req, res) => { group.put(/\/admin\/tags\/(?<tagname>.*)/, lib.loggedin, async (req, res) => {
if(!req.params.tagname || !req.post.newtag) { if (!req.params.tagname || !req.post.newtag) {
return res.json({ return res.json({
success: false, success: false,
msg: 'missing tagname or newtag', msg: 'missing tagname or newtag',
@@ -154,7 +172,7 @@ export default router => {
const tagname = decodeURIComponent(req.params.tagname); const tagname = decodeURIComponent(req.params.tagname);
const newtag = req.post.newtag; const newtag = req.post.newtag;
if(['sfw', 'nsfw'].includes(tagname) || ['sfw', 'nsfw'].includes(newtag)) { if (['sfw', 'nsfw'].includes(tagname) || ['sfw', 'nsfw'].includes(newtag)) {
return res.json({ return res.json({
msg: 'f0ck you' msg: 'f0ck you'
}, 405); // method not allowed }, 405); // method not allowed
@@ -166,8 +184,8 @@ export default router => {
where tag = ${tagname} where tag = ${tagname}
limit 1 limit 1
`)[0]; `)[0];
if(!tmptag) { if (!tmptag) {
return res.json({ return res.json({
success: false, success: false,
msg: 'no tag found' msg: 'no tag found'
@@ -175,10 +193,9 @@ export default router => {
} }
const q = (await db` const q = (await db`
update "tags" set ${ update "tags" set ${db({
db({ tag: newtag
tag: newtag }, 'tag')
}, 'tag')
} }
where tag = ${tagname} where tag = ${tagname}
returning * returning *
@@ -195,7 +212,7 @@ export default router => {
const searchString = req.url.qs.q; const searchString = req.url.qs.q;
if(searchString?.length <= 1) { if (searchString?.length <= 1) {
reply.error = 'too short lol'; reply.error = 'too short lol';
return res.json(reply); return res.json(reply);
} }
@@ -212,7 +229,7 @@ export default router => {
`; `;
reply.success = true; reply.success = true;
reply.suggestions = search(q, searchString); reply.suggestions = search(q, searchString);
} catch(err) { } catch (err) {
reply.error = err.msg; reply.error = err.msg;
} }
@@ -220,7 +237,7 @@ export default router => {
}); });
group.post(/\/admin\/deletepost$/, lib.auth, async (req, res) => { group.post(/\/admin\/deletepost$/, lib.auth, async (req, res) => {
if(!req.post.postid) { if (!req.post.postid) {
return res.json({ return res.json({
success: false, success: false,
msg: 'no postid' msg: 'no postid'
@@ -228,7 +245,7 @@ export default router => {
} }
const id = +req.post.postid; const id = +req.post.postid;
if(id <= 1) { if (id <= 1) {
return res.json({ return res.json({
success: false success: false
}); });
@@ -243,7 +260,7 @@ export default router => {
limit 1 limit 1
`; `;
if(f0ck.length === 0) { if (f0ck.length === 0) {
return res.json({ return res.json({
success: false, success: false,
msg: `f0ck ${id}: f0ck not found` msg: `f0ck ${id}: f0ck not found`
@@ -251,15 +268,15 @@ export default router => {
} }
await db`update "items" set active = 'false' where id = ${id}`; await db`update "items" set active = 'false' where id = ${id}`;
await fs.copyFile(`./public/b/${f0ck[0].dest}`, `./deleted/b/${f0ck[0].dest}`).catch(_=>{});
await fs.copyFile(`./public/t/${id}.webp`, `./deleted/t/${id}.webp`).catch(_=>{});
await fs.unlink(`./public/b/${f0ck[0].dest}`).catch(_=>{});
await fs.unlink(`./public/t/${id}.webp`).catch(_=>{});
if(f0ck[0].mime.startsWith('audio')) { await fs.copyFile(`./public/b/${f0ck[0].dest}`, `./deleted/b/${f0ck[0].dest}`).catch(_ => { });
await fs.copyFile(`./public/ca/${id}.webp`, `./deleted/ca/${id}.webp`).catch(_=>{}); await fs.copyFile(`./public/t/${id}.webp`, `./deleted/t/${id}.webp`).catch(_ => { });
await fs.unlink(`./public/ca/${id}.webp`).catch(_=>{}); await fs.unlink(`./public/b/${f0ck[0].dest}`).catch(_ => { });
await fs.unlink(`./public/t/${id}.webp`).catch(_ => { });
if (f0ck[0].mime.startsWith('audio')) {
await fs.copyFile(`./public/ca/${id}.webp`, `./deleted/ca/${id}.webp`).catch(_ => { });
await fs.unlink(`./public/ca/${id}.webp`).catch(_ => { });
} }
res.json({ res.json({
@@ -269,14 +286,14 @@ export default router => {
group.post(/\/admin\/togglefav$/, lib.loggedin, async (req, res) => { group.post(/\/admin\/togglefav$/, lib.loggedin, async (req, res) => {
const postid = +req.post.postid; const postid = +req.post.postid;
let favs = await db` let favs = await db`
select user_id select user_id
from "favorites" from "favorites"
where item_id = ${+postid} where item_id = ${+postid}
`; `;
if(Object.values(favs).filter(u => u.user_id === req.session.id)[0]) { if (Object.values(favs).filter(u => u.user_id === req.session.id)[0]) {
// del fav // del fav
await db` await db`
delete from "favorites" delete from "favorites"
@@ -287,11 +304,10 @@ export default router => {
else { else {
// add fav // add fav
await db` await db`
insert into "favorites" ${ insert into "favorites" ${db({
db({ item_id: +postid,
item_id: +postid, user_id: +req.session.id
user_id: +req.session.id }, 'item_id', 'user_id')
}, 'item_id', 'user_id')
} }
`; `;
} }
@@ -310,7 +326,7 @@ export default router => {
favs favs
}); });
}); });
}); });
return router; return router;

View File

@@ -0,0 +1,260 @@
import { promises as fs } from "fs";
import db from '../../sql.mjs';
import lib from '../../lib.mjs';
import cfg from '../../config.mjs';
import queue from '../../queue.mjs';
import path from "path";
// Native multipart form data parser
const parseMultipart = (buffer, boundary) => {
const parts = {};
const boundaryBuffer = Buffer.from(`--${boundary}`);
const segments = [];
let start = 0;
let idx;
while ((idx = buffer.indexOf(boundaryBuffer, start)) !== -1) {
if (start !== 0) {
segments.push(buffer.slice(start, idx - 2)); // -2 for \r\n before boundary
}
start = idx + boundaryBuffer.length + 2; // +2 for \r\n after boundary
}
for (const segment of segments) {
const headerEnd = segment.indexOf('\r\n\r\n');
if (headerEnd === -1) continue;
const headers = segment.slice(0, headerEnd).toString();
const body = segment.slice(headerEnd + 4);
const nameMatch = headers.match(/name="([^"]+)"/);
const filenameMatch = headers.match(/filename="([^"]+)"/);
const contentTypeMatch = headers.match(/Content-Type:\s*([^\r\n]+)/i);
if (nameMatch) {
const name = nameMatch[1];
if (filenameMatch) {
parts[name] = {
filename: filenameMatch[1],
contentType: contentTypeMatch ? contentTypeMatch[1] : 'application/octet-stream',
data: body
};
} else {
parts[name] = body.toString().trim();
}
}
}
return parts;
};
// Collect request body as buffer with debug logging
const collectBody = (req) => {
return new Promise((resolve, reject) => {
console.log('[UPLOAD DEBUG] collectBody started');
const chunks = [];
req.on('data', chunk => {
// console.log(`[UPLOAD DEBUG] chunk received: ${chunk.length} bytes`);
chunks.push(chunk);
});
req.on('end', () => {
console.log(`[UPLOAD DEBUG] Stream ended. Total size: ${chunks.reduce((acc, c) => acc + c.length, 0)}`);
resolve(Buffer.concat(chunks));
});
req.on('error', err => {
console.error('[UPLOAD DEBUG] Stream error:', err);
reject(err);
});
// Ensure stream is flowing
if (req.isPaused()) {
console.log('[UPLOAD DEBUG] Stream was paused, resuming...');
req.resume();
}
});
};
export default router => {
router.group(/^\/api\/v2/, group => {
group.post(/\/upload$/, lib.loggedin, async (req, res) => {
try {
console.log('[UPLOAD DEBUG] Request received');
// Use stored content type if available (from middleware bypass), otherwise use header
const contentType = req._multipartContentType || req.headers['content-type'] || '';
const boundaryMatch = contentType.match(/boundary=(.+)$/);
if (!boundaryMatch) {
console.log('[UPLOAD DEBUG] No boundary found');
return res.json({ success: false, msg: 'Invalid content type' }, 400);
}
let body;
if (req.bodyPromise) {
console.log('[UPLOAD DEBUG] Waiting for buffered body from middleware promise...');
body = await req.bodyPromise;
console.log('[UPLOAD DEBUG] Received body from promise');
} else if (req.rawBody) {
console.log('[UPLOAD DEBUG] Using buffered body from middleware');
body = req.rawBody;
} else {
console.log('[UPLOAD DEBUG] Collecting body via collectBody...');
body = await collectBody(req);
}
if (!body) {
return res.json({ success: false, msg: 'Failed to receive file body' }, 400);
}
console.log('[UPLOAD DEBUG] Body size:', body.length);
const parts = parseMultipart(body, boundaryMatch[1]);
console.log('[UPLOAD DEBUG] Parsed parts:', Object.keys(parts));
// Validate required fields
const file = parts.file;
const rating = parts.rating; // 'sfw' or 'nsfw'
const tagsRaw = parts.tags; // comma-separated tags
if (!file || !file.data) {
return res.json({ success: false, msg: 'No file provided' }, 400);
}
if (!rating || !['sfw', 'nsfw'].includes(rating)) {
return res.json({ success: false, msg: 'Rating (sfw/nsfw) is required' }, 400);
}
const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(t => t.length > 0) : [];
if (tags.length < 3) {
return res.json({ success: false, msg: 'At least 3 tags are required' }, 400);
}
// Validate MIME type
const allowedMimes = ['video/mp4', 'video/webm'];
let mime = file.contentType;
if (!allowedMimes.includes(mime)) {
return res.json({ success: false, msg: `Invalid file type. Only mp4 and webm allowed. Got: ${mime}` }, 400);
}
// Validate file size
const maxfilesize = cfg.main.maxfilesize;
const size = file.data.length;
if (size > maxfilesize) {
return res.json({
success: false,
msg: `File too large. Max: ${lib.formatSize(maxfilesize)}, Got: ${lib.formatSize(size)}`
}, 400);
}
// Generate UUID for filename
const uuid = await queue.genuuid();
const ext = mime === 'video/mp4' ? 'mp4' : 'webm';
const filename = `${uuid}.${ext}`;
const tmpPath = `./tmp/${filename}`;
const destPath = `./public/b/${filename}`;
// Save file temporarily
await fs.writeFile(tmpPath, file.data);
// Verify MIME with file command
const actualMime = (await queue.exec(`file --mime-type -b ${tmpPath}`)).stdout.trim();
if (!allowedMimes.includes(actualMime)) {
await fs.unlink(tmpPath).catch(() => { });
return res.json({ success: false, msg: `Invalid file type detected: ${actualMime}` }, 400);
}
// Generate checksum
const checksum = (await queue.exec(`sha256sum ${tmpPath}`)).stdout.trim().split(" ")[0];
// Check for repost
const repost = await queue.checkrepostsum(checksum);
if (repost) {
await fs.unlink(tmpPath).catch(() => { });
return res.json({
success: false,
msg: `This file already exists`,
repost: repost
}, 409);
}
// Move to public folder
await fs.copyFile(tmpPath, destPath);
await fs.unlink(tmpPath).catch(() => { });
// Insert into database (active=false for admin approval)
await db`
insert into items ${db({
src: '',
dest: filename,
mime: actualMime,
size: size,
checksum: checksum,
username: req.session.user,
userchannel: 'web',
usernetwork: 'web',
stamp: ~~(Date.now() / 1000),
active: false
}, 'src', 'dest', 'mime', 'size', 'checksum', 'username', 'userchannel', 'usernetwork', 'stamp', 'active')
}
`;
// Get the new item ID
const itemid = await queue.getItemID(filename);
// Generate thumbnail
try {
await queue.genThumbnail(filename, actualMime, itemid, '');
} catch (err) {
await queue.exec(`magick ./mugge.png ./public/t/${itemid}.webp`);
}
// Assign rating tag (sfw=1, nsfw=2)
const ratingTagId = rating === 'sfw' ? 1 : 2;
await db`
insert into tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: req.session.id })}
`;
// Assign user tags
for (const tagName of tags) {
// Check if tag exists, create if not
let tagRow = await db`
select id from tags where normalized = slugify(${tagName}) limit 1
`;
let tagId;
if (tagRow.length === 0) {
// Create new tag
await db`
insert into tags ${db({ tag: tagName }, 'tag')}
`;
tagRow = await db`
select id from tags where normalized = slugify(${tagName}) limit 1
`;
}
tagId = tagRow[0].id;
// Assign tag to item
await db`
insert into tags_assign ${db({ item_id: itemid, tag_id: tagId, user_id: req.session.id })}
on conflict do nothing
`;
}
return res.json({
success: true,
msg: 'Upload successful! Your upload is pending admin approval.',
itemid: itemid
});
} catch (err) {
console.error('[UPLOAD ERROR]', err);
return res.json({ success: false, msg: 'Upload failed: ' + err.message }, 500);
}
});
});
return router;
};

View File

@@ -4,7 +4,7 @@ import lib from "../lib.mjs";
import f0cklib from "../routeinc/f0cklib.mjs"; import f0cklib from "../routeinc/f0cklib.mjs";
const auth = async (req, res, next) => { const auth = async (req, res, next) => {
if(!req.session) if (!req.session)
return res.redirect("/login"); return res.redirect("/login");
return next(); return next();
}; };
@@ -21,7 +21,7 @@ export default (router, tpl) => {
limit 1 limit 1
`; `;
if(!query.length) { if (!query.length) {
return res.reply({ return res.reply({
code: 404, code: 404,
body: tpl.render('error', { body: tpl.render('error', {
@@ -44,11 +44,11 @@ export default (router, tpl) => {
session: !!req.session, session: !!req.session,
limit: 99999999 limit: 99999999
}); });
if('items' in f0cks) { if ('items' in f0cks) {
count.f0cks = f0cks.items.length; count.f0cks = f0cks.items.length;
f0cks.items = f0cks.items.slice(0, 50); f0cks.items = f0cks.items.slice(0, 50);
} }
} catch(err) { } catch (err) {
f0cks = false; f0cks = false;
count.f0cks = 0; count.f0cks = 0;
} }
@@ -60,11 +60,11 @@ export default (router, tpl) => {
session: !!req.session, session: !!req.session,
limit: 99999999 limit: 99999999
}); });
if('items' in favs) { if ('items' in favs) {
count.favs = favs.items.length; count.favs = favs.items.length;
favs.items = favs.items.slice(0, 50); favs.items = favs.items.slice(0, 50);
} }
} catch(err) { } catch (err) {
favs = false; favs = false;
count.favs = 0; count.favs = 0;
} }
@@ -93,7 +93,7 @@ export default (router, tpl) => {
session: !!req.session, session: !!req.session,
url: req.url.pathname url: req.url.pathname
}); });
if(!data.success) { if (!data.success) {
return res.reply({ return res.reply({
code: 404, code: 404,
body: tpl.render('error', { body: tpl.render('error', {
@@ -103,6 +103,10 @@ export default (router, tpl) => {
}); });
} }
if (mode === 'item') {
data.hidePagination = true;
}
return res.reply({ body: tpl.render(mode, data, req) }); return res.reply({ body: tpl.render(mode, data, req) });
}); });
@@ -123,10 +127,10 @@ export default (router, tpl) => {
let referertmp = req.headers.referer; let referertmp = req.headers.referer;
let referer = ""; let referer = "";
if(referertmp?.match(/f0ck\.me/)) if (referertmp?.match(/f0ck\.me/))
referer = referertmp.split("/").slice(3).join("/"); referer = referertmp.split("/").slice(3).join("/");
if(cfg.allowedModes[mode]) { if (cfg.allowedModes[mode]) {
const blah = { const blah = {
user_id: req.session.id, user_id: req.session.id,
mode: mode, mode: mode,
@@ -134,8 +138,7 @@ export default (router, tpl) => {
}; };
await db` await db`
insert into "user_options" ${ insert into "user_options" ${db(blah, 'user_id', 'mode', 'theme')
db(blah, 'user_id', 'mode', 'theme')
} }
on conflict ("user_id") do update set on conflict ("user_id") do update set
mode = excluded.mode, mode = excluded.mode,

View File

@@ -0,0 +1,79 @@
import db from "../sql.mjs";
import lib from "../lib.mjs";
export default (router, tpl) => {
router.get(/^\/register(\/)?$/, async (req, res) => {
if (req.cookies.session) {
return res.writeHead(302, { "Location": "/" }).end();
}
res.reply({
body: tpl.render("register", { theme: req.cookies.theme ?? "f0ck" })
});
});
router.post(/^\/register(\/)?$/, async (req, res) => {
const { username, password, password_confirm, token } = req.post;
const renderError = (msg) => {
return res.reply({
body: tpl.render("register", { theme: req.cookies.theme ?? "f0ck", error: msg })
});
};
if (!username || !password || !token) return renderError("All fields are required");
if (password !== password_confirm) return renderError("Passwords do not match");
if (username.length < 3) return renderError("Username too short");
// Password complexity check
if (password.length < 20) return renderError("Password must be at least 20 characters long");
// Check token
const tokenRow = await db`
select * from invite_tokens where token = ${token} and is_used = false
`;
if (tokenRow.length === 0) {
return renderError("Invalid or used invite token");
}
// Check user existence
const existing = await db`select id from "user" where "login" = ${username.toLowerCase()}`;
if (existing.length > 0) return renderError("Username taken");
// Create User
const hash = await lib.hash(password);
const ts = ~~(Date.now() / 1e3);
// Note: Creating user. Assuming columns based on typical structure.
// Need to check 'user' table columns to be safe, but usually: login, password, user (display name), created_at, admin
// I'll assume 'user' is display name and 'login' is lowercase
const newUser = await db`
insert into "user" ("login", "password", "user", "created_at", "admin")
values (${username.toLowerCase()}, ${hash}, ${username}, to_timestamp(${ts}), false)
returning id
`;
const userId = newUser[0].id;
// Mark token used
await db`
update invite_tokens
set is_used = true, used_by = ${userId}
where id = ${tokenRow[0].id}
`;
// Get a valid avatar ID (default to 1)
const avatarRow = await db`select id from items where id = 1`;
const avatarId = avatarRow.length > 0 ? 1 : (await db`select id from items limit 1`)[0].id;
await db`
insert into user_options (user_id, mode, theme, fullscreen, avatar)
values (${userId}, 3, 'amoled', 0, ${avatarId})
`;
// Redirect to home with login success message
return res.writeHead(302, { "Location": "/?login=success" }).end();
});
return router;
};

View File

@@ -0,0 +1,56 @@
import crypto from 'crypto';
export default (router, tpl) => {
router.get(/^\/tag_image\/(?<tag>.+)$/, async (req, res) => {
const tag = decodeURIComponent(req.params.tag);
// Create a deterministic hash from the tag
const hash = crypto.createHash('md5').update(tag).digest('hex');
// Escape character for SVG
const escapeXml = (unsafe) => {
return unsafe.replace(/[<>&'"]/g, (c) => {
switch (c) {
case '<': return '&lt;';
case '>': return '&gt;';
case '&': return '&amp;';
case '\'': return '&apos;';
case '"': return '&quot;';
}
});
};
const displayTag = escapeXml(tag);
// Generate colors from hash
const c1 = '#' + hash.substring(0, 6);
const c2 = '#' + hash.substring(6, 12);
const c3 = '#' + hash.substring(12, 18);
// Generate some deterministic numbers for shapes
const n1 = parseInt(hash.substring(18, 20), 16);
const n2 = parseInt(hash.substring(20, 22), 16);
const svg = `
<svg width="300" height="150" viewBox="0 0 300 150" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:${c1};stop-opacity:1" />
<stop offset="100%" style="stop-color:${c2};stop-opacity:1" />
</linearGradient>
</defs>
<rect width="300" height="150" fill="url(#grad)" />
<circle cx="${n1}%" cy="${n2}%" r="${(n1 + n2) / 4}" fill="${c3}" fill-opacity="0.3" />
<circle cx="${100 - n1}%" cy="${100 - n2}%" r="${(n1 + n2) / 3}" fill="${c3}" fill-opacity="0.2" />
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="sans-serif" font-size="24" fill="#fff" fill-opacity="0.9" font-weight="bold">${displayTag}</text>
</svg>
`.trim();
res.writeHead(200, {
'Content-Type': 'image/svg+xml',
'Cache-Control': 'public, max-age=86400'
});
res.end(svg);
});
return router;
};

View File

@@ -4,8 +4,10 @@ import lib from "./inc/lib.mjs";
import cuffeo from "cuffeo"; import cuffeo from "cuffeo";
import { promises as fs } from "fs"; import { promises as fs } from "fs";
import flummpress from "flummpress"; import flummpress from "flummpress";
import { handleUpload } from "./upload_handler.mjs";
process.on('unhandledRejection', err => { process.on('unhandledRejection', err => {
if (err.code === 'ERR_HTTP_HEADERS_SENT') return;
console.error(err); console.error(err);
throw err; throw err;
}); });
@@ -19,7 +21,7 @@ process.on('unhandledRejection', err => {
this.level = args.level || 0; this.level = args.level || 0;
this.name = args.name; this.name = args.name;
this.active = args.hasOwnProperty("active") ? args.active : true; this.active = args.hasOwnProperty("active") ? args.active : true;
this.clients = args.clients || [ "irc", "tg", "slack" ]; this.clients = args.clients || ["irc", "tg", "slack"];
this.f = args.f; this.f = args.f;
}, },
bot: await new cuffeo(cfg.clients) bot: await new cuffeo(cfg.clients)
@@ -27,7 +29,7 @@ process.on('unhandledRejection', err => {
console.time("loading"); console.time("loading");
const modules = { const modules = {
events: (await fs.readdir("./src/inc/events")).filter(f => f.endsWith(".mjs")), events: (await fs.readdir("./src/inc/events")).filter(f => f.endsWith(".mjs")),
trigger: (await fs.readdir("./src/inc/trigger")).filter(f => f.endsWith(".mjs")) trigger: (await fs.readdir("./src/inc/trigger")).filter(f => f.endsWith(".mjs"))
}; };
@@ -41,7 +43,7 @@ process.on('unhandledRejection', err => {
console.timeLog("loading", `${dir}/${mod}`); console.timeLog("loading", `${dir}/${mod}`);
return res; return res;
}))).flat(2) }))).flat(2)
})))).reduce((a, b) => ({...a, ...b})); })))).reduce((a, b) => ({ ...a, ...b }));
blah.events.forEach(event => { blah.events.forEach(event => {
console.timeLog("loading", `registering event > ${event.name}`); console.timeLog("loading", `registering event > ${event.name}`);
@@ -61,15 +63,16 @@ process.on('unhandledRejection', err => {
const router = app.router; const router = app.router;
const tpl = app.tpl; const tpl = app.tpl;
app.use(async (req, res) => { app.use(async (req, res) => {
// sessionhandler // sessionhandler
req.session = false; req.session = false;
if(req.url.pathname.match(/^\/(s|b|t|ca)\//)) if (req.url.pathname.match(/^\/(s|b|t|ca)\//))
return; return;
req.theme = req.cookies.theme || 'amoled'; req.theme = 'amoled';
req.fullscreen = req.cookies.fullscreen || 0; req.fullscreen = req.cookies.fullscreen || 0;
if(req.cookies.session) { if (req.cookies.session) {
const user = await db` const user = await db`
select "user".id, "user".login, "user".user, "user".admin, "user_sessions".id as sess_id, "user_options".* select "user".id, "user".login, "user".user, "user".admin, "user_sessions".id as sess_id, "user_options".*
from "user_sessions" from "user_sessions"
@@ -78,8 +81,8 @@ process.on('unhandledRejection', err => {
where "user_sessions".session = ${lib.md5(req.cookies.session)} where "user_sessions".session = ${lib.md5(req.cookies.session)}
limit 1 limit 1
`; `;
if(user.length === 0) { if (user.length === 0) {
return res.writeHead(307, { // delete session return res.writeHead(307, { // delete session
"Cache-Control": "no-cache, public", "Cache-Control": "no-cache, public",
"Set-Cookie": "session=; Path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT", "Set-Cookie": "session=; Path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT",
@@ -91,28 +94,26 @@ process.on('unhandledRejection', err => {
// log last action // log last action
await db` await db`
update "user_sessions" set ${ update "user_sessions" set ${db({
db({ last_used: ~~(Date.now() / 1e3),
last_used: ~~(Date.now() / 1e3), last_action: req.url.pathname,
last_action: req.url.pathname, browser: req.headers['user-agent']
browser: req.headers['user-agent'] }, 'last_used', 'last_action', 'browser')
}, 'last_used', 'last_action', 'browser')
} }
where id = ${+user[0].sess_id} where id = ${+user[0].sess_id}
`; `;
req.session.theme = req.cookies.theme; req.session.theme = req.cookies.theme;
req.session.fullscreen = req.cookies.fullscreen; req.session.fullscreen = req.cookies.fullscreen;
// update userprofile // update userprofile
await db` await db`
insert into "user_options" ${ insert into "user_options" ${db({
db({ user_id: +user[0].id,
user_id: +user[0].id, mode: user[0].mode ?? 0,
mode: user[0].mode ?? 0, theme: req.session.theme ?? 'amoled',
theme: req.session.theme ?? 'amoled', fullscreen: req.session.fullscreen || 0
fullscreen: req.session.fullscreen || 0 }, 'user_id', 'mode', 'theme', 'fullscreen')
}, 'user_id', 'mode', 'theme', 'fullscreen')
} }
on conflict ("user_id") do update set on conflict ("user_id") do update set
mode = excluded.mode, mode = excluded.mode,
@@ -123,6 +124,15 @@ process.on('unhandledRejection', err => {
} }
}); });
// Bypass middleware for direct upload handling
app.use(async (req, res) => {
if (req.method === 'POST' && req.url.pathname === '/api/v2/upload') {
await handleUpload(req, res);
// Modify URL to prevent router matching and double execution
req.url.pathname = '/handled_upload_bypass';
}
});
tpl.views = "views"; tpl.views = "views";
tpl.debug = true; tpl.debug = true;
tpl.cache = false; tpl.cache = false;

254
src/upload_handler.mjs Normal file
View File

@@ -0,0 +1,254 @@
import { promises as fs } from "fs";
import db from "./inc/sql.mjs";
import lib from "./inc/lib.mjs";
import cfg from "./inc/config.mjs";
import queue from "./inc/queue.mjs";
import path from "path";
// Native multipart form data parser
const parseMultipart = (buffer, boundary) => {
const parts = {};
const boundaryBuffer = Buffer.from(`--${boundary}`);
const segments = [];
let start = 0;
let idx;
while ((idx = buffer.indexOf(boundaryBuffer, start)) !== -1) {
if (start !== 0) {
segments.push(buffer.slice(start, idx - 2)); // -2 for \r\n before boundary
}
start = idx + boundaryBuffer.length + 2; // +2 for \r\n after boundary
}
for (const segment of segments) {
const headerEnd = segment.indexOf('\r\n\r\n');
if (headerEnd === -1) continue;
const headers = segment.slice(0, headerEnd).toString();
const body = segment.slice(headerEnd + 4);
const nameMatch = headers.match(/name="([^"]+)"/);
const filenameMatch = headers.match(/filename="([^"]+)"/);
const contentTypeMatch = headers.match(/Content-Type:\s*([^\r\n]+)/i);
if (nameMatch) {
const name = nameMatch[1];
if (filenameMatch) {
parts[name] = {
filename: filenameMatch[1],
contentType: contentTypeMatch ? contentTypeMatch[1] : 'application/octet-stream',
data: body
};
} else {
parts[name] = body.toString().trim();
}
}
}
return parts;
};
// Collect request body as buffer
const collectBody = (req) => {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', chunk => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
// Ensure stream flows
if (req.isPaused()) req.resume();
});
};
// Helper for JSON response
const sendJson = (res, data, code = 200) => {
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
};
export const handleUpload = async (req, res) => {
console.log('[UPLOAD HANDLER] Started');
// Manual Session Lookup (because flummpress middleware might not have finished)
// We assume req.cookies is populated by framework or we need to parse it?
// index.mjs accesses req.cookies directly, so we assume it works.
let user = [];
if (req.cookies && req.cookies.session) {
user = await db`
select "user".id, "user".login, "user".user, "user".admin, "user_sessions".id as sess_id, "user_options".*
from "user_sessions"
left join "user" on "user".id = "user_sessions".user_id
left join "user_options" on "user_options".user_id = "user_sessions".user_id
where "user_sessions".session = ${lib.md5(req.cookies.session)}
limit 1
`;
}
if (user.length === 0) {
console.log('[UPLOAD HANDLER] Unauthorized - No valid session found');
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
}
// Mock req.session for consistency if needed by other logic, though we use 'user[0]' here
req.session = user[0];
console.log('[UPLOAD HANDLER] Authorized:', req.session.user);
try {
const contentType = req.headers['content-type'] || '';
const boundaryMatch = contentType.match(/boundary=(.+)$/);
if (!boundaryMatch) {
console.log('[UPLOAD HANDLER] No boundary');
return sendJson(res, { success: false, msg: 'Invalid content type' }, 400);
}
console.log('[UPLOAD HANDLER] Collecting body...');
const body = await collectBody(req);
console.log('[UPLOAD HANDLER] Body collected, size:', body.length);
const parts = parseMultipart(body, boundaryMatch[1]);
// Validate required fields
const file = parts.file;
const rating = parts.rating;
const tagsRaw = parts.tags;
if (!file || !file.data) {
return sendJson(res, { success: false, msg: 'No file provided' }, 400);
}
if (!rating || !['sfw', 'nsfw'].includes(rating)) {
return sendJson(res, { success: false, msg: 'Rating (sfw/nsfw) is required' }, 400);
}
const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(t => t.length > 0) : [];
if (tags.length < 3) {
return sendJson(res, { success: false, msg: 'At least 3 tags are required' }, 400);
}
// Validate MIME type
const allowedMimes = ['video/mp4', 'video/webm'];
let mime = file.contentType;
if (!allowedMimes.includes(mime)) {
return sendJson(res, { success: false, msg: `Invalid file type. Only mp4 and webm allowed. Got: ${mime}` }, 400);
}
// Validate file size
const maxfilesize = cfg.main.maxfilesize;
const size = file.data.length;
if (size > maxfilesize) {
return sendJson(res, {
success: false,
msg: `File too large. Max: ${lib.formatSize(maxfilesize)}, Got: ${lib.formatSize(size)}`
}, 400);
}
// Generate UUID
const uuid = await queue.genuuid();
const ext = mime === 'video/mp4' ? 'mp4' : 'webm';
const filename = `${uuid}.${ext}`;
const tmpPath = `./tmp/${filename}`;
const destPath = `./public/b/${filename}`;
// Ensure directories exist
await fs.mkdir('./tmp', { recursive: true });
await fs.mkdir('./public/b', { recursive: true });
// Save temporarily
await fs.writeFile(tmpPath, file.data);
// Verify MIME
const actualMime = (await queue.exec(`file --mime-type -b ${tmpPath}`)).stdout.trim();
if (!allowedMimes.includes(actualMime)) {
await fs.unlink(tmpPath).catch(() => { });
return sendJson(res, { success: false, msg: `Invalid file type detected: ${actualMime}` }, 400);
}
// Constants
const checksum = (await queue.exec(`sha256sum ${tmpPath}`)).stdout.trim().split(" ")[0];
// Check repost
const repost = await queue.checkrepostsum(checksum);
if (repost) {
await fs.unlink(tmpPath).catch(() => { });
return sendJson(res, {
success: false,
msg: `This file already exists`,
repost: repost
}, 409);
}
// Move to public
await fs.copyFile(tmpPath, destPath);
await fs.unlink(tmpPath).catch(() => { });
// Insert
await db`
insert into items ${db({
src: '',
dest: filename,
mime: actualMime,
size: size,
checksum: checksum,
username: req.session.user,
userchannel: 'web',
usernetwork: 'web',
stamp: ~~(Date.now() / 1000),
active: false
}, 'src', 'dest', 'mime', 'size', 'checksum', 'username', 'userchannel', 'usernetwork', 'stamp', 'active')
}
`;
const itemid = await queue.getItemID(filename);
// Thumbnail
try {
await queue.genThumbnail(filename, actualMime, itemid, '');
} catch (err) {
await queue.exec(`magick ./mugge.png ./public/t/${itemid}.webp`);
}
// Tags
const ratingTagId = rating === 'sfw' ? 1 : 2;
await db`
insert into tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: req.session.id })}
`;
for (const tagName of tags) {
let tagRow = await db`
select id from tags where normalized = slugify(${tagName}) limit 1
`;
let tagId;
if (tagRow.length === 0) {
await db`
insert into tags ${db({ tag: tagName }, 'tag')}
`;
tagRow = await db`
select id from tags where normalized = slugify(${tagName}) limit 1
`;
}
tagId = tagRow[0].id;
await db`
insert into tags_assign ${db({ item_id: itemid, tag_id: tagId, user_id: req.session.id })}
on conflict do nothing
`;
}
return sendJson(res, {
success: true,
msg: 'Upload successful! Your upload is pending admin approval.',
itemid: itemid
});
} catch (err) {
console.error('[UPLOAD HANDLER ERROR]', err);
return sendJson(res, { success: false, msg: 'Upload failed: ' + err.message }, 500);
}
};

View File

@@ -1,13 +1,22 @@
@include(snippets/header) @include(snippets/header)
<div id="main"> <div id="main">
<div class="about"> <div class="about">
<p>Welcome stranger!</p> <p>Welcome stranger!</p>
<p>bringing you some of the greatest webms from the past, the present and the future!</p> <p>bringing you some of the greatest webms from the past, the present and the future!</p>
<p>Enjoy your stay.</p> <p>How to use it?</p>
<img style="width: 200px" src="/s/img/cockfag.png" alt="cockfag"> <p>shortcuts</p>
<p>If you have any questions you can reach out via Mail.</p> <ul>
<p>mail: admin@w0bm.com</p> <li>k = search</li>
<p>Please also make yourself familiar with the <a href="/terms">Terms Of Service</a></p> <li>r = random</li>
<li>p = toggle safe for rating</li>
<li>i = open tag input</li>
<li>x = del</li>
<li>scroll up/down inside video or inside the controls triggers next or prev</li>
<li>Arrow keys trigger next or prev</li>
</ul>
<p>If you have any questions you can reach out via Mail.</p>
<p>mail: admin@w0bm.com</p>
<p>Please also make yourself familiar with the <a href="/terms">Terms Of Service</a></p>
</div>
</div> </div>
</div> @include(snippets/footer)
@include(snippets/footer)

View File

@@ -6,17 +6,19 @@
<span>Hier entsteht eine Internetpräsenz!</span><br> <span>Hier entsteht eine Internetpräsenz!</span><br>
<hr> <hr>
<p>f0ck stats: @if(typeof totals !== "undefined") <p>f0ck stats: @if(typeof totals !== "undefined")
total: {{ totals.total }} | tagged: {{ totals.tagged }} | untagged: {{ totals.untagged }} | sfw: {{ totals.sfw }} | nsfw: {{ totals.nsfw }} total: {{ totals.total }} | tagged: {{ totals.tagged }} | untagged: {{ totals.untagged }} | sfw: {{ totals.sfw }}
@endif</p> | nsfw: {{ totals.nsfw }}
@endif</p>
<hr> <hr>
<div class="admintools"> <div class="admintools">
<p>Adminwerkzeuge</p> <p>Adminwerkzeuge</p>
<ul> <ul>
<!-- <li><a href="/admin/log">Logs</a></li> <!-- <li><a href="/admin/log">Logs</a></li> -->
<li><a href="/admin/recover">Recover f0cks</a></li> --> <li><a href="/admin/approve">Approval Queue</a></li>
<li><a href="/admin/sessions">Sessions</a></li> <li><a href="/admin/sessions">Sessions</a></li>
<li><a href="/admin/tokens">Invite Tokens</a></li>
</ul> </ul>
</div> </div>
</div> </div>
</div> </div>
@include(snippets/footer) @include(snippets/footer)

192
views/admin/approve.html Normal file
View File

@@ -0,0 +1,192 @@
@include(snippets/header)
<div id="main">
<div class="container">
<h1>APPROVAL QUEUE</h1>
<p>Items here are pending approval.</p>
<table class="table" style="width: 100%">
<thead>
<tr>
<td>Preview</td>
<td>ID</td>
<td>Uploader</td>
<td>Type</td>
<td>Action</td>
</tr>
</thead>
<tbody>
@each(posts as post)
<tr>
<td>
<video controls loop muted preload="metadata" style="max-height: 200px; max-width: 300px;">
<source src="/b/{{ post.dest }}" type="{{ post.mime }}">
</video>
</td>
<td>{{ post.id }}</td>
<td>{{ post.username }}</td>
<td>{{ post.mime }}</td>
<td>
<a href="/admin/approve/?id={{ post.id }}" class="badge badge-success">Approve</a>
<a href="/admin/deny/?id={{ post.id }}" class="badge badge-danger btn-deny-async">Deny /
Delete</a>
</td>
</tr>
@endeach
@if(posts.length === 0)
<tr>
<td colspan="5">No pending items.</td>
</tr>
@endif
</tbody>
</table>
<br>
@if(typeof pages !== 'undefined' && pages > 1)
<div class="pagination" style="display: flex; gap: 10px; align-items: center; justify-content: center;">
@if(page > 1)
<a href="/admin/approve?page={{ page - 1 }}" class="badge badge-secondary">&laquo; Prev</a>
@endif
<span>Page {{ page }} of {{ pages }}</span>
@if(page < pages) <a href="/admin/approve?page={{ page + 1 }}" class="badge badge-secondary">Next
&raquo;</a>
@endif
</div>
<br>
@endif
<div style="text-align: center; margin-bottom: 20px;">
<button id="btn-deny-all" class="badge badge-danger" onclick="window.handleDenyAll(event)"
style="font-size: 1.2em; padding: 10px 20px; border: none; cursor: pointer;">Deny All</button>
</div>
<a href="/admin">Back to Admin</a>
</div>
</div>
<!-- Custom Modal -->
<div id="custom-modal"
style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); justify-content: center; align-items: center; z-index: 1000;">
<div
style="background: #222; color: #fff; padding: 20px; border-radius: 8px; max-width: 400px; text-align: center; border: 1px solid #444;">
<h3 id="modal-title" style="margin-top: 0;">Confirm Action</h3>
<p id="modal-text">Are you sure?</p>
<div style="display: flex; justify-content: space-around; margin-top: 20px;">
<button id="modal-cancel" class="badge badge-secondary"
style="border: none; padding: 10px 20px; cursor: pointer;">Cancel</button>
<button id="modal-confirm" class="badge badge-danger"
style="border: none; padding: 10px 20px; cursor: pointer;">Confirm</button>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Dynamic Button Text
const btnDenyAllInit = document.getElementById('btn-deny-all');
if (btnDenyAllInit) {
const count = document.querySelectorAll('.btn-deny-async').length;
btnDenyAllInit.innerText = 'Deny All (' + count + ' visible)';
}
const modal = document.getElementById('custom-modal');
const modalTitle = document.getElementById('modal-title');
const modalText = document.getElementById('modal-text');
const btnConfirm = document.getElementById('modal-confirm');
const btnCancel = document.getElementById('modal-cancel');
let pendingAction = null;
const showModal = (title, text, action) => {
modalTitle.innerText = title;
modalText.innerText = text;
pendingAction = action;
modal.style.display = 'flex';
btnConfirm.onclick = async () => {
if (!pendingAction) return;
btnConfirm.disabled = true;
btnConfirm.innerText = 'Processing...';
try {
await pendingAction();
closeModal();
} catch (e) {
alert('Error: ' + e.message);
} finally {
btnConfirm.disabled = false;
btnConfirm.innerText = 'Confirm';
}
};
};
const closeModal = () => {
modal.style.display = 'none';
pendingAction = null;
};
if (btnCancel) btnCancel.onclick = closeModal;
// Single Deny
document.querySelectorAll('.btn-deny-async').forEach(btn => {
btn.addEventListener('click', e => {
e.preventDefault();
const url = btn.getAttribute('href');
const row = btn.closest('tr');
showModal('Deny Item', 'Permanently delete this item?', async () => {
const res = await fetch(url);
if (res.ok) {
row.style.opacity = '0';
setTimeout(() => row.remove(), 300);
} else {
throw new Error('Request failed');
}
});
});
});
// Global handler for Deny All
window.handleDenyAll = (e) => {
e.preventDefault();
console.log('Deny All clicked (Inline)');
const allBtn = [...document.querySelectorAll('.btn-deny-async')];
// Map to {id, element}
const targets = allBtn.map(b => {
const href = b.getAttribute('href');
const match = href ? href.match(/[?&]id=([^&]+)/) : null;
if (!match && href) console.log('No ID match for href:', href);
return match ? { id: match[1], btn: b } : null;
}).filter(item => item);
const ids = targets.map(t => t.id);
console.log('Deny List:', ids);
if (ids.length === 0) return alert('No items to deny');
showModal('Deny ALL', 'Permanently delete ' + ids.length + ' visible items?', async () => {
const res = await fetch('/admin/deny-multi', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids })
});
const json = await res.json();
if (json.success) {
closeModal(); // UX Polish: Close modal immediately
// Visual Removal
targets.forEach(t => {
const row = t.btn.closest('tr');
if (row) {
row.style.opacity = '0';
}
});
// Allow transition then reload
setTimeout(() => {
window.location.reload();
}, 500);
} else {
throw new Error(json.msg || 'Failed');
}
});
};
});
</script>
@include(snippets/footer)

89
views/admin/tokens.html Normal file
View File

@@ -0,0 +1,89 @@
@include(snippets/header)
<div class="container" style="padding-top: 20px;">
<h2>Invite Tokens</h2>
<div style="margin-bottom: 20px; text-align: right;">
<button id="generate-token" class="btn-upload" style="width: auto; padding: 10px 20px;">Generate New
Token</button>
</div>
<div class="upload-form" style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse; color: var(--white);">
<thead>
<tr style="border-bottom: 1px solid var(--nav-border-color); text-align: left;">
<th style="padding: 10px;">Token</th>
<th style="padding: 10px;">Status</th>
<th style="padding: 10px;">Used By</th>
<th style="padding: 10px;">Created</th>
<th style="padding: 10px;">Actions</th>
</tr>
</thead>
<tbody id="token-list">
<!-- Populated by JS -->
</tbody>
</table>
</div>
</div>
<script>
const loadTokens = async () => {
try {
console.log('Loading tokens...');
const res = await fetch('/api/v2/admin/tokens');
const data = await res.json();
console.log('Tokens data:', data);
if (data.success) {
const tbody = document.getElementById('token-list');
tbody.innerHTML = data.tokens.map(t =>
'<tr style="border-bottom: 1px solid rgba(255,255,255,0.05);">' +
'<td style="padding: 10px; font-family: monospace; font-size: 1.1em; color: var(--accent);">' + t.token + '</td>' +
'<td style="padding: 10px;">' +
(t.is_used ? '<span style="color: #ff6b6b">Used</span>' : '<span style="color: #51cf66">Available</span>') +
'</td>' +
'<td style="padding: 10px;">' + (t.used_by_name || '-') + '</td>' +
'<td style="padding: 10px;">' + new Date(parseInt(t.created_at) * 1000).toLocaleString() + '</td>' +
'<td style="padding: 10px;">' +
(!t.is_used ? '<button onclick="deleteToken(' + t.id + ')" class="btn-remove" style="padding: 5px 10px; font-size: 0.8em;">Delete</button>' : '') +
'</td>' +
'</tr>'
).join('');
}
} catch (e) { console.error(e); }
};
const generateToken = async () => {
console.log('Generating...');
try {
const res = await fetch('/api/v2/admin/tokens/create', { method: 'POST' });
const data = await res.json();
console.log('Gen result:', data);
if (data.success) {
loadTokens();
} else {
alert('Failed: ' + data.msg);
}
} catch (e) {
console.error(e);
alert('Error: ' + e.message);
}
};
const deleteToken = async (id) => {
if (!confirm('Delete this token?')) return;
const res = await fetch('/api/v2/admin/tokens/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id })
});
const data = await res.json();
if (data.success) {
loadTokens();
}
};
document.getElementById('generate-token').addEventListener('click', generateToken);
loadTokens();
</script>
@include(snippets/footer)

View File

@@ -1,5 +1,5 @@
@include(snippets/header) @include(snippets/header)
<canvas class="hidden-xs" id="bg"></canvas>
<div class="wrapper"> <div class="wrapper">
<div id="main"> <div id="main">

35
views/register.html Normal file
View File

@@ -0,0 +1,35 @@
<!doctype f0ck>
<html theme="amoled">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>register</title>
<link href="/s/css/f0ck.css" rel="stylesheet" />
</head>
<body type="login">
<form class="login-form" method="post" action="/register">
<h2 style="text-align: center; margin-bottom: 20px;">Register</h2>
@if(typeof error !== 'undefined')
<div style="color: #ff6b6b; margin-bottom: 10px; text-align: center;">{{ error }}</div>
@endif
<input type="text" name="username" placeholder="username" autocomplete="off" required />
<input type="password" name="password" placeholder="password" autocomplete="off" required minlength="20"
title="Must be at least 20 characters long." />
<input type="password" name="password_confirm" placeholder="confirm password" autocomplete="off" /><br>
<input type="text" name="token" placeholder="invite token" autocomplete="off" /><br>
<p style="text-align: left; font-size: 0.9em; margin: 10px 0; color: #fff;">
<input type="checkbox" id="tos-page" name="tos" required />
<label for="tos-page">I have read and accept the <a href="/terms" target="_blank"
style="color: var(--accent); text-decoration: underline;">Terms of Service</a> and I am at least 18
years old</label>
</p>
<input type="submit" value="Register" />
<div style="margin-top: 15px; text-align: center;">
<a href="/login" style="color: var(--accent); text-decoration: none;">Back to Login</a>
</div>
</form>
</body>
</html>

View File

@@ -2,19 +2,15 @@
<div class="settings"> <div class="settings">
<h1>Settings</h1> <h1>Settings</h1>
<h2>Site settings</h2> <h2>Site settings</h2>
<div class="themes">
<h3>Themes</h3> <div class="modes">
@each(themes as t) <h3>Modes</h3>
<a href="/theme/{{ t }}">{{ t }}</a> <span>Current: {{ modes[session.mode] ?? 'sfw' }}</span>
@endeach <a class="dropdown-item" href="/mode/0">sfw</a>
</div> <a class="dropdown-item" href="/mode/1">nsfw</a>
<div class="modes"> <a class="dropdown-item" href="/mode/2">untagged</a>
<h3>Modes</h3> <a class="dropdown-item" href="/mode/3">all</a>
<span>Current: {{ modes[session.mode] ?? 'sfw' }}</span> </div>
@for(let i = 0; i < modes.length; i++)
<a class="dropdown-item" href="/mode/{{ i }}">{{ modes[i] }}</a>
@endfor
</div>
<h2>Account</h2> <h2>Account</h2>
<table class="table"> <table class="table">
<tbody> <tbody>
@@ -31,7 +27,8 @@
<td>{!! session.user !!}</td> <td>{!! session.user !!}</td>
</tr> </tr>
<tr> <tr>
<td>@if(session.avatar)<a href="/{{ session.avatar }}"><img id="img_avatar" src="/t/{{ session.avatar }}.webp"></a>@endif</td> <td>@if(session.avatar)<a href="/{{ session.avatar }}"><img id="img_avatar"
src="/t/{{ session.avatar }}.webp"></a>@endif</td>
<td><input type="text" class="input" name="i_avatar" value="{{ session.avatar }}"></td> <td><input type="text" class="input" name="i_avatar" value="{{ session.avatar }}"></td>
</tr> </tr>
<tr> <tr>
@@ -39,7 +36,7 @@
<td><input type="text" class="input" name="i_mail" placeholder="hashed" disabled></td> <td><input type="text" class="input" name="i_mail" placeholder="hashed" disabled></td>
</tr> </tr>
<tr> <tr>
<td colspan="2"><input type="submit" id="s_avatar" value="save"></td> <td><input type="submit" id="s_avatar" value="save"></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -55,7 +52,7 @@
</thead> </thead>
<tbody> <tbody>
@each(sessions as sess) @each(sessions as sess)
<tr@if(sess.id === session.sess_id) style="background-color: rgb(0, 89, 0)"@endif> <tr@if(sess.id===session.sess_id) style="background-color: rgb(0, 89, 0)" @endif>
<td>{{ sess.kmsi ? '&#9875;' : '' }}</td> <td>{{ sess.kmsi ? '&#9875;' : '' }}</td>
<td tooltip="{{ sess.browser }}" flow="right"> <td tooltip="{{ sess.browser }}" flow="right">
<p>{{ sess.id }}</p> <p>{{ sess.id }}</p>
@@ -66,9 +63,9 @@
<p>created_at: {{ new Date(sess.created_at * 1e3).toLocaleString("de-DE") }}</p> <p>created_at: {{ new Date(sess.created_at * 1e3).toLocaleString("de-DE") }}</p>
</td> </td>
<td><a href="{{ sess.last_action }}" target="_blank">{{ sess.last_action }}</a></td> <td><a href="{{ sess.last_action }}" target="_blank">{{ sess.last_action }}</a></td>
</tr> </tr>
@endeach @endeach
</tbody> </tbody>
</table> </table>
</div> </div>
@include(snippets/footer) @include(snippets/footer)

View File

@@ -1,10 +1,32 @@
<script async src="/s/js/theme.js?v=@mtime(/public/s/js/theme.js)"></script> <div id="delete-tag-modal" class="modal-overlay" style="display:none;">
<script src="/s/js/v0ck.js?v=@mtime(/public/s/js/v0ck.js)"></script> <div class="modal-content">
<script src="/s/js/f0ck.js?v=@mtime(/public/s/js/f0ck.js)"></script> <h3>Delete Tag?</h3>
@if(session && session.admin) <p>Are you sure you want to delete the tag <strong id="delete-tag-name"></strong>?</p>
<script src="/s/js/admin.js?v=@mtime(/public/s/js/admin.js)"></script> <div class="modal-actions">
@elseif(session && !session.admin) <button id="delete-tag-confirm" class="btn-danger">Delete</button>
<script src="/s/js/user.js?v=@mtime(/public/s/js/user.js)"></script> <button id="delete-tag-cancel" class="btn-secondary">Cancel</button>
@endif </div>
</div>
</div>
<div id="delete-item-modal" class="modal-overlay" style="display:none;">
<div class="modal-content">
<h3>Delete Item?</h3>
<p>Are you sure you want to delete item <strong id="delete-item-id"></strong> by <strong
id="delete-item-poster"></strong>?</p>
<div class="modal-actions">
<button id="delete-item-confirm" class="btn-danger">Delete</button>
<button id="delete-item-cancel" class="btn-secondary">Cancel</button>
</div>
</div>
</div>
<script async src="/s/js/theme.js?v=@mtime(/public/s/js/theme.js)"></script>
<script src="/s/js/v0ck.js?v=@mtime(/public/s/js/v0ck.js)"></script>
<script src="/s/js/f0ck.js?v=@mtime(/public/s/js/f0ck.js)"></script>
@if(session && session.admin)
<script src="/s/js/admin.js?v=@mtime(/public/s/js/admin.js)"></script>
@elseif(session && !session.admin)
<script src="/s/js/user.js?v=@mtime(/public/s/js/user.js)"></script>
@endif
</body> </body>
</html>
</html>

View File

@@ -1,14 +1,19 @@
<!doctype html> <!doctype html>
<html lang="en" theme="@if(typeof theme !== "undefined"){{ theme }}@endif" res="@if(typeof fullscreen !== "undefined"){{ fullscreen == 1 ? 'fullscreen' : '' }}@endif"> <html lang="en" theme="@if(typeof theme !== 'undefined'){{ theme }}@endif"
res="@if(typeof fullscreen !== 'undefined'){{ fullscreen == 1 ? 'fullscreen' : '' }}@endif">
<head> <head>
@if(typeof item !== "undefined")<title>f0bm - {{ item.id }}</title>@else<title>f0bm</title>@endif @if(typeof item !== 'undefined')<title>f0bm - {{ item.id }}</title>@else<title>f0bm</title>@endif
<link rel="icon" type="image/gif" href="/s/img/favicon.png" /> <link rel="icon" type="image/gif" href="/s/img/favicon.png" />
<link rel="stylesheet" href="/s/css/f0ck.css?v=@mtime(/public/s/css/f0ck.css)"> <link rel="stylesheet" href="/s/css/f0ck.css?v=@mtime(/public/s/css/f0ck.css)">
<link rel="stylesheet" href="/s/css/w0bm.css?v=@mtime(/public/s/css/w0bm.css)"> <link rel="stylesheet" href="/s/css/w0bm.css?v=@mtime(/public/s/css/w0bm.css)">
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
@if(typeof item !== "undefined")<link rel="canonical" href="https://w0bm.com/{{ item.id }}" />@endif @if(typeof item !== 'undefined')
<link rel="canonical" href="https://w0bm.com/{{ item.id }}" />@endif
</head> </head>
<body> <body>
<!-- hier splitting betreiben --> <!-- hier splitting betreiben -->
@include(snippets/navbar) <canvas class="hidden-xs" id="bg"></canvas>
@include(snippets/navbar)

View File

@@ -0,0 +1,7 @@
@each(items as item)
<a href="{{ link.main }}{{ item.id }}" data-mime="{{ item.mime }}"
data-mode="{{ item.tag_id ? ['','sfw','nsfw'][item.tag_id] : 'null' }}"
style="background-image: url('/t/{{ item.id }}.webp')">
<p></p>
</a>
@endeach

View File

@@ -2,18 +2,44 @@
<!-- logged in --> <!-- logged in -->
<nav class="navbar navbar-expand-lg"> <nav class="navbar navbar-expand-lg">
<a class="navbar-brand" href="/"><span class="f0ck" width="" height="">w0bm.com</span></a> <a class="navbar-brand" href="/"><span class="f0ck" width="" height="">w0bm.com</span></a>
<div class="nav-left-group">
<div class="navigation-links-guest"> <div class="nav-user-dropdown">
<ol> <button class="nav-user-btn" id="nav-user-toggle">
{{ session.user }} ▾
</button>
<div class="nav-user-menu" id="nav-user-menu">
<a href="/user/{{ session.user.toLowerCase() }}">profile</a>
<a href="/user/{{ session.user.toLowerCase() }}/favs">favs</a>
<a href="/upload">upload</a>
@if(session.admin)
<a href="/admin">admin</a>
@endif
<a href="/settings">settings</a>
<a href="/about">about</a>
<div class="nav-user-divider"></div>
<a href="/logout">logout</a>
</div>
</div>
<div class="nav-links">
<a href="/tags">tags</a> <a href="/tags">tags</a>
<a href="/about">about</a>
@if(!/^\/\d$/.test(url.pathname)) @if(!/^\/\d$/.test(url.pathname))
<a href="/random">rand</a> <a href="/random" id="nav-random" title="Random"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13"
fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd"
d="M0 3.5A.5.5 0 0 1 .5 3H1c2.202 0 3.827 1.24 4.874 2.418.49.552.865 1.102 1.126 1.532.26-.43.636-.98 1.126-1.532C9.173 4.24 10.798 3 13 3v1c-1.798 0-3.173 1.01-4.126 2.082A9.624 9.624 0 0 0 7.556 8a9.624 9.624 0 0 0 1.317 1.918C9.828 10.99 11.204 12 13 12v1c-2.202 0-3.827-1.24-4.874-2.418A10.595 10.595 0 0 1 7 9.05c-.26.43-.636.98-1.126 1.532C4.827 11.76 3.202 13 1 13H.5a.5.5 0 0 1 0-1H1c1.798 0 3.173-1.01 4.126-2.082A9.624 9.624 0 0 0 6.444 8a9.624 9.624 0 0 0-1.317-1.918C4.172 5.01 2.796 4 1 4H.5a.5.5 0 0 1-.5-.5z" />
<path
d="M13 5.466V1.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192zm0 9v-3.932a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192z" />
</svg></a>
@endif @endif
</ol> <a href="#" id="nav-search-btn" title="Search"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13"
fill="currentColor" viewBox="0 0 16 16">
<path
d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z" />
</svg></a>
</div>
</div> </div>
<!-- show pagination only for tags and main page --> <!-- show pagination only for tags and main page -->
@if(!/^\/\d$/.test(url.pathname)) @if(typeof hidePagination === 'undefined' || !hidePagination)
<div class="collapse navbar-collapse show" id="navbarSupportedContent"> <div class="collapse navbar-collapse show" id="navbarSupportedContent">
<div class="pagination-container-fluid"> <div class="pagination-container-fluid">
<div class="pagination-wrapper"> <div class="pagination-wrapper">
@@ -27,18 +53,38 @@
<!-- not logged in --> <!-- not logged in -->
<nav class="navbar navbar-expand-lg"> <nav class="navbar navbar-expand-lg">
<a class="navbar-brand" href="/"><span class="f0ck" width="" height="">w0bm.com</span></a> <a class="navbar-brand" href="/"><span class="f0ck" width="" height="">w0bm.com</span></a>
<div class="nav-left-group">
<div class="navigation-links-guest"> <div class="nav-user-dropdown">
<ol> <button class="nav-user-btn" id="nav-visitor-toggle">
guest ▾
</button>
<div class="nav-user-menu" id="nav-visitor-menu">
<a href="#" id="nav-login-btn">Login</a>
<a href="#" id="nav-register-btn">Register</a>
<div class="nav-user-divider"></div>
<a href="/about">about</a>
</div>
</div>
<div class="nav-links">
<a href="/tags">tags</a> <a href="/tags">tags</a>
<a href="/about">about</a>
@if(!/^\/\d$/.test(url.pathname)) @if(!/^\/\d$/.test(url.pathname))
<a href="/random">rand</a> <a href="/random" id="nav-random" title="Random"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13"
fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd"
d="M0 3.5A.5.5 0 0 1 .5 3H1c2.202 0 3.827 1.24 4.874 2.418.49.552.865 1.102 1.126 1.532.26-.43.636-.98 1.126-1.532C9.173 4.24 10.798 3 13 3v1c-1.798 0-3.173 1.01-4.126 2.082A9.624 9.624 0 0 0 7.556 8a9.624 9.624 0 0 0 1.317 1.918C9.828 10.99 11.204 12 13 12v1c-2.202 0-3.827-1.24-4.874-2.418A10.595 10.595 0 0 1 7 9.05c-.26.43-.636.98-1.126 1.532C4.827 11.76 3.202 13 1 13H.5a.5.5 0 0 1 0-1H1c1.798 0 3.173-1.01 4.126-2.082A9.624 9.624 0 0 0 6.444 8a9.624 9.624 0 0 0-1.317-1.918C4.172 5.01 2.796 4 1 4H.5a.5.5 0 0 1-.5-.5z" />
<path
d="M13 5.466V1.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192zm0 9v-3.932a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192z" />
</svg></a>
@endif @endif
</ol> <a href="#" id="nav-search-btn-guest" title="Search"><svg xmlns="http://www.w3.org/2000/svg" width="13"
height="13" fill="currentColor" viewBox="0 0 16 16">
<path
d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z" />
</svg></a>
</div>
</div> </div>
<!-- show pagination only for tags and main page --> <!-- show pagination only for tags and main page -->
@if(!/^\/\d$/.test(url.pathname)) @if(typeof hidePagination === 'undefined' || !hidePagination)
<div class="collapse navbar-collapse show" id="navbarSupportedContent"> <div class="collapse navbar-collapse show" id="navbarSupportedContent">
<div class="pagination-container-fluid"> <div class="pagination-container-fluid">
<div class="pagination-wrapper"> <div class="pagination-wrapper">
@@ -48,5 +94,42 @@
</div> </div>
@endif @endif
</nav> </nav>
@endif
@endif <!-- Login Modal -->
<div id="login-modal" style="display: none;">
<div class="login-modal-content">
<button id="login-modal-close">&times;</button>
<form class="login-form" method="post" action="/login">
<img class="login-image" src="/s/img/w0bm_mosh_banner_by_marderchen.gif" alt="Login Banner">
<input type="text" name="username" placeholder="Username" autocomplete="off" required />
<input type="password" name="password" placeholder="Password" autocomplete="off" required />
<p style="text-align: left; font-size: 0.9em; margin: 0;"><input type="checkbox" id="kmsi-modal" name="kmsi" />
<label for="kmsi-modal">Stay signed in</label>
</p>
<button type="submit">Login</button>
</form>
</div>
</div>
<!-- Register Modal -->
<div id="register-modal" style="display: none;">
<div class="login-modal-content">
<button id="register-modal-close">&times;</button>
<form class="login-form" method="post" action="/register">
<h2 style="text-align: center; margin-bottom: 20px;">Register</h2>
<input type="text" name="username" placeholder="username" autocomplete="off" required />
<input type="password" name="password" placeholder="password" autocomplete="off" required minlength="20"
title="Must be at least 20 characters long." />
<input type="password" name="password_confirm" placeholder="confirm password" autocomplete="off" required />
<input type="text" name="token" placeholder="invite token" autocomplete="off" required />
<p style="text-align: left; font-size: 0.9em; margin: 0; color: #fff;">
<input type="checkbox" id="tos-modal" name="tos" required />
<label for="tos-modal">I have read and accept the <a href="/terms" target="_blank"
style="color: var(--accent); text-decoration: underline;">Terms of Service</a> and I am at least 18 years
old</label>
</p>
<button type="submit">Create Account</button>
</form>
</div>
</div>

View File

@@ -2,27 +2,33 @@
<div id="main"> <div id="main">
<div class="container"> <div class="container">
<h3 style="text-align: center;"></h3> <h3 style="text-align: center;"></h3>
<div class="tags"> <div class="tags-grid">
@if(session) @if(session)
@each(toptags_regged as toptag) @each(toptags_regged as toptag)
<div class="tag badge badge-light mr-2"> <a href="/tag/{!! toptag.tag !!}" class="tag-card">
<div class="tagbox-body"> <div class="tag-card-image">
<span class="toptag_id">{!! toptag.tag !!}</span> <img src="/tag_image/{!! toptag.tag !!}" loading="lazy" alt="{!! toptag.tag !!}">
<span class="toptag_tag"><a href="/tag/{!! toptag.tag !!}">{{ toptag.total_items }}</a></span>
</div> </div>
</div> <div class="tag-card-content">
<span class="tag-name">#{!! toptag.tag !!}</span>
<span class="tag-count">{{ toptag.total_items }} posts</span>
</div>
</a>
@endeach @endeach
@else @else
@each(toptags as toptag) @each(toptags as toptag)
<div class="tag badge badge-light mr-2"> <a href="/tag/{!! toptag.tag !!}" class="tag-card">
<div class="tagbox-body"> <div class="tag-card-image">
<span class="toptag_id">{!! toptag.tag !!}</span> <img src="/tag_image/{!! toptag.tag !!}" loading="lazy" alt="{!! toptag.tag !!}">
<span class="toptag_tag"><a href="/tag/{!! toptag.tag !!}">{{ toptag.total_items }}</a></span>
</div> </div>
</div> <div class="tag-card-content">
<span class="tag-name">#{!! toptag.tag !!}</span>
<span class="tag-count">{{ toptag.total_items }} posts</span>
</div>
</a>
@endeach @endeach
@endif @endif
</div> </div>
</div> </div>
</div> </div>
@include(snippets/footer) @include(snippets/footer)

View File

@@ -1,56 +1,96 @@
@include(snippets/header) @include(snippets/header)
<div id="main"> <div id="main">
<div class="tos"> <div class="tos">
<p>Terms of Service</p> <h1 style="text-align: center; margin-bottom: 20px;">Terms of Service</h1>
<ol> <ol>
<li>Acceptance of Terms</li> <li>
<p>By accessing and using this website, you acknowledge that your access is a privilege, not a right. If you do not agree with these terms, you are free to leave at any time.</p> <strong>Acceptance of Terms</strong>
<li>No Claims</li> <p>By accessing and using this website, you acknowledge that your access is a privilege, not a right. If
<p>Visitors to this website have no claims whatsoever against the website owner or operators. Access to the website and its content is provided as-is, with no guarantees, warranties, or entitlements of any kind.</p> you do not agree with these terms, you are free to leave at any time.</p>
<li>No Liability</li> </li>
<p>This website and its operators assume no liability for any errors, omissions, inaccuracies, or any other issues that may arise from the use of this site. Use of this website is entirely at your own risk.</p> <li>
<li>No Warranty</li> <strong>No Claims</strong>
<p>There is no warranty regarding the completeness, accuracy, reliability, or availability of the content provided on this website. The content may change at any time without notice.</p> <p>Visitors to this website have no claims whatsoever against the website owner or operators. Access to
<li>Compliance with Requests</li> the website and its content is provided as-is, with no guarantees, warranties, or entitlements of
<p>The website owner reserves the right to remove content, restrict access, or comply with any valid legal or personal requests at their sole discretion.</p> any kind.</p>
<li>Changes to Terms</li> </li>
<p>These terms may be updated at any time without prior notice. It is your responsibility to review them periodically.</p> <li>
</ol> <strong>No Liability</strong>
<p>Data Privacy</p> <p>This website and its operators assume no liability for any errors, omissions, inaccuracies, or any
<ol> other issues that may arise from the use of this site. Use of this website is entirely at your own
<li>No Data Logging</li> risk.</p>
<p>This website does not collect, store, or log any personal data, including IP addresses or other identifying information of its visitors. No server-side logs are maintained.</p> </li>
<li>
<li>Use of Cookies</li> <strong>No Warranty</strong>
<p>Upon changing the theme, a single cookie is set. This cookie solely stores the name of the currently active theme to enhance the visual experience. It does not contain any personal data, tracking information, or other identifiers.</p> <p>There is no warranty regarding the completeness, accuracy, reliability, or availability of the
content provided on this website. The content may change at any time without notice.</p>
<li>Cookie Control</li> </li>
<p>The cookie is purely of cosmetic nature and not essential for the website's functionality. Users can disable cookies for this website entirely via their browser settings without affecting their ability to access and use the site.</p> <li>
<strong>Compliance with Requests</strong>
<li>No Third-Party Tracking</li> <p>The website owner reserves the right to remove content, restrict access, or comply with any valid
<p>This website does not use third-party tracking services, analytics tools, or embedded content that collects user data.</p> legal or personal requests at their sole discretion.</p>
</li>
<li>User Accounts</li> <li>
<p>When a former visitor is granted access with an account, the following data is collected:</p> <strong>Changes to Terms</strong>
<ul> <p>These terms may be updated at any time without prior notice. It is your responsibility to review them
<li>The User Agent</li> periodically.</p>
<li>The Timestamp of the first login</li> </li>
<li>The Timestamp of the account's last usage</li> </ol>
<li>The User's last recorded action</li>
</ul>
<li>Email Communication</li> <h2 style="margin-top: 30px;">Data Privacy</h2>
<p>If you send me an email your mail is stored on our server, we can make a connection to your Email-Address and your user account if you contact us this way.</p> <ol>
<p>The Emails are not deleted after being answered.</p> <li>
<strong>No Data Logging</strong>
<p>This website does not collect, store, or log any personal data, including IP addresses or other
identifying information of its visitors. No server-side logs are maintained.</p>
</li>
<li>
<strong>Use of Cookies</strong>
<p>Upon changing the theme, a single cookie is set. This cookie solely stores the name of the currently
active theme to enhance the visual experience. It does not contain any personal data, tracking
information, or other identifiers.</p>
</li>
<li>
<strong>Cookie Control</strong>
<p>The cookie is purely of cosmetic nature and not essential for the website's functionality. Users can
disable cookies for this website entirely via their browser settings without affecting their ability
to access and use the site.</p>
</li>
<li>
<strong>No Third-Party Tracking</strong>
<p>This website does not use third-party tracking services, analytics tools, or embedded content that
collects user data.</p>
</li>
<li>
<strong>User Accounts</strong>
<p>When a former visitor is granted access with an account, the following data is collected:</p>
<ul>
<li>The User Agent</li>
<li>The Timestamp of the first login</li>
<li>The Timestamp of the account's last usage</li>
<li>The User's last recorded action</li>
</ul>
<br>
</li>
<li>
<strong>Email Communication</strong>
<p>If you send me an email your mail is stored on our server, we can make a connection to your
Email-Address and your user account if you contact us this way.</p>
<p>The Emails are not deleted after being answered.</p>
</li>
<li>
<strong>Fully complying with Art. 15 GDPR</strong>
<p>You can ask anytime what data we have of you and how we use it, see Email Communication too.</p>
</li>
<li>
<strong>Changes to This Policy</strong>
<p>This privacy policy may be updated from time to time. Users are encouraged to review it periodically
to stay informed about any changes.</p>
</li>
</ol>
<li>Fully complying with Art. 15 GDPR</li> <p style="margin-top: 30px; font-style: italic;">By using this website, you acknowledge and accept the terms of
<p>You can ask anytime what data we have of you and how we use it, see Email Communication too.</p> service and the data privacy policy.</p>
</div>
<li>Changes to This Policy</li>
<p>This privacy policy may be updated from time to time. Users are encouraged to review it periodically to stay informed about any changes.</p>
<p>By using this website, you acknowledge and accept the terms of service and the data privacy policy.</p>
</ol>
</div>
</div> </div>
@include(snippets/footer) @include(snippets/footer)

View File

@@ -1,37 +1,115 @@
@include(snippets/header) @include(snippets/header)
<div class="upload"> <link rel="stylesheet" href="/s/css/upload.css">
<h5>Upload</h5>
<p>To add videos to the w0bm catalogue you must join our <a href="https://t.me/+w97TCd988ehkNWEy">Telegram</a> group</p> <div class="upload-container">
<h5>Content Guideline</h5> <h2>Upload Content</h2>
<p>w0bm follows strict principles when it comes to content, please keep this in mind.</p>
<p>We do not want content that</p> <details class="content-guidelines">
<ul> <summary>Content Guidelines (Click to expand)</summary>
<li>glorifies Nazis</li> <div class="guidelines-content">
<li>sexualizes children and minors</li> <p>We want this place to be fun. Keep it cool, keep it legal.</p>
<li>is political</li> <div class="guidelines-grid">
<li>glorifies military</li> <div class="guidelines-do">
<li>depicts gore</li> <h5>Do's (Vibes & Hypnosis)</h5>
<li>depicts acts of terrorism</li> <ul>
<li>depicts violence and cruelty against animals</li> <li>Cool, relaxing, or weird "vibing" content</li>
</ul> <li>Classic-style loops (Flash era vibes)</li>
<p>We want content that</p> <li>High-quality, hypnotic edits (PMVs welcome)</li>
<ul> <li>Interesting, freaky, or just plain cool stuff</li>
<li>is cool</li> </ul>
<li>has deeper value</li> </div>
<li>is fun to watch</li> <div class="guidelines-dont">
<li>has a vibe to it</li> <h5>Don'ts (The Banhammer)</h5>
<li>can be looped for 5000 times and doesnt get boring</li> <ul>
</ul> <li>Political commentary, preaching, or "pol" bait</li>
<p>but in general we welcome content that has been curated beforehand by the uploader and believe that they understand the vibe.</p> <li>Gore, extreme violence, or animal cruelty (Instant Ban)</li>
<p>Content that is deemed NSFW (Not Safe For Work) MUST be tagged with "nsfw"</p> <li>Illegal content (CP, Terror, etc.) (Instant Ban)</li>
<p>This list is subject to change, please review it periodically.</p> <li>Boring, unedited, or lengthy videos</li>
<br> </ul>
<h5>How it works</h5> </div>
<ul> </div>
<li>The maximum filesize for direct file upload is 20MB and cannot be exceeded.</li> </div>
<li>There is a much higher limit for non-direct uploads via sending a URL.</li> </details>
<li>You can send a link to the group and put a !f behind it and the bot will pick it up and add it to w0bm.</li>
<li>In the menu below the bots message you can select the rating and additional tags.</li> @if(session)
</ul> <form id="upload-form" class="upload-form" enctype="multipart/form-data">
<div class="form-section">
<label>Video File <span class="required">*</span></label>
<div class="drop-zone" id="drop-zone">
<input type="file" id="file-input" name="file" accept="video/mp4,video/webm">
<div class="drop-zone-prompt">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"
style="opacity: 0.7; margin-bottom: 1rem;">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
<p style="font-size: 1.1rem; font-weight: 500;">Drop your video here</p>
<p style="font-size: 0.9rem; opacity: 0.6;">(mp4 or webm)</p>
</div>
<!-- Preview Container -->
<div class="file-preview" id="file-preview" style="display: none;">
<!-- Video will be injected here via JS -->
<div class="file-meta-row">
<div class="file-info">
<span class="file-name" id="file-name"></span>
<span class="file-size" id="file-size"></span>
</div>
<button type="button" class="btn-remove" id="remove-file" title="Remove File"></button>
</div>
</div>
</div>
</div>
<div class="form-section">
<label>Rating <span class="required">*</span></label>
<div class="rating-options">
<label class="rating-option">
<input type="radio" name="rating" value="sfw" required>
<span class="rating-label sfw">SFW</span>
</label>
<label class="rating-option">
<input type="radio" name="rating" value="nsfw">
<span class="rating-label nsfw">NSFW</span>
</label>
</div>
</div>
<div class="form-section">
<label>Tags <span class="required">*</span> <span class="tag-count" id="tag-count">(0/3
minimum)</span></label>
<div class="tag-input-container">
<div class="tags-list" id="tags-list"></div>
<input type="text" id="tag-input" placeholder="Type a tag and press Enter" autocomplete="off">
<div class="tag-suggestions" id="tag-suggestions"></div>
</div>
<input type="hidden" name="tags" id="tags-hidden">
</div>
<div class="form-actions">
<button type="submit" id="submit-btn" class="btn-upload" disabled>
<span class="btn-text">Select a file</span>
<span class="btn-loading" style="display: none;">Uploading...</span>
</button>
</div>
<div class="upload-progress" id="upload-progress" style="display: none;">
<div class="progress-bar">
<div class="progress-fill" id="progress-fill"></div>
</div>
<span class="progress-text" id="progress-text">0%</span>
</div>
<div class="upload-status" id="upload-status"></div>
</form>
@else
<div class="login-required">
<h3>Authentication Required</h3>
<p>You must be logged in to upload content to w0bm.</p>
<a href="/login" class="btn-login">Login</a>
</div>
@endif
</div> </div>
<script src="/s/js/upload.js"></script>
@include(snippets/footer) @include(snippets/footer)