Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
145 commits
Select commit Hold shift + click to select a range
d6ea00f
Get basic working (#109)
itexpert120 Mar 25, 2025
810bb9a
fix polyfills
elliotBraem Mar 25, 2025
e8599af
Explore Page (#108)
saadiqbal-dev Mar 25, 2025
30cbbfe
Header + Explore Page Style (#113)
saadiqbal-dev Mar 27, 2025
d4018f7
Profile page (#120)
itexpert120 Apr 11, 2025
cf66339
[FEATURE] Create Feed Page - DRAFT (#121)
saadiqbal-dev Apr 11, 2025
79d572d
Curate Engine Step 1
elliotBraem Apr 11, 2025
db92ef9
update to main
elliotBraem Apr 11, 2025
e369f24
fmt
elliotBraem Apr 11, 2025
1b4c006
Feat/submissions page (#127)
saadiqbal-dev Apr 28, 2025
8d08308
Merge branch 'main' of https://github.com/PotLock/curatedotfun into s…
elliotBraem Apr 28, 2025
908f537
Feed Page Tabs (#130)
saadiqbal-dev Apr 30, 2025
6d9aeb9
Merge branch 'main' of https://github.com/PotLock/curatedotfun into s…
elliotBraem May 1, 2025
6fc7637
set tanstack routes (#132)
elliotBraem May 1, 2025
ea2137c
[Task]: Add connect button to feed page (#131)
louisdevzz May 1, 2025
0ecfab3
uses prod data
elliotBraem May 2, 2025
cd12908
Update changes to latest staging
saadiqbal-dev May 2, 2025
ba55dd3
Revert "Update changes to latest staging"
saadiqbal-dev May 2, 2025
1c4044a
Fix Sort By Oldest (#136)
louisdevzz May 9, 2025
8cd6e82
UI fixes (#138)
saadiqbal-dev May 9, 2025
5e91466
Fix: Leaderboard improvements (#140)
louisdevzz May 9, 2025
59de163
clean up
elliotBraem May 10, 2025
4a411fb
wallet wip
elliotBraem May 12, 2025
72ea8fa
todo
elliotBraem May 12, 2025
716b956
Merge branch 'main' of https://github.com/PotLock/curatedotfun into s…
elliotBraem May 12, 2025
94bf4e4
Merge branch 'staging' into feat/wallet
elliotBraem May 12, 2025
3ad71c0
auth flow, wip
elliotBraem May 12, 2025
5561575
types clean up
elliotBraem May 12, 2025
8dda6af
fix types
elliotBraem May 12, 2025
9078b4e
login modal wip
elliotBraem May 12, 2025
7110ff5
modals
elliotBraem May 12, 2025
af6bfda
controller, service, successful create account
elliotBraem May 13, 2025
c3b2582
clean with data, metadata, and pattern, validation, and json schema
elliotBraem May 13, 2025
592fa49
add migration doc
elliotBraem May 13, 2025
2baaa98
add activity and delete user
elliotBraem May 13, 2025
8b92f11
fix migration
elliotBraem May 13, 2025
ad177fa
add seed remote method
elliotBraem May 13, 2025
620a554
fix naming
elliotBraem May 13, 2025
ac88430
fix script call
elliotBraem May 13, 2025
5edba9d
file extension
elliotBraem May 13, 2025
5cb5ec5
remove build schema
elliotBraem May 13, 2025
6eef52a
proper build time
elliotBraem May 13, 2025
017e0f0
fix Dockerfile
elliotBraem May 13, 2025
1f77f1d
rsbuild
elliotBraem May 13, 2025
5aefc9f
Standard Header Component + Responsivenss Fixes (#146)
saadiqbal-dev May 13, 2025
750a427
fix broken link
saadiqbal-dev May 13, 2025
1473e34
don't distribute on staging
elliotBraem May 14, 2025
6aff38c
fix path
elliotBraem May 14, 2025
d93df7a
env log
elliotBraem May 14, 2025
9bbcc39
comment out
elliotBraem May 14, 2025
754209f
railway env
elliotBraem May 14, 2025
6607554
fix: Profile adjustments (#153)
louisdevzz May 16, 2025
4126f79
Login Modal Fixes (#154)
saadiqbal-dev May 18, 2025
3b1c83b
organize
elliotBraem May 18, 2025
19c491a
fmt
elliotBraem May 18, 2025
14baf96
update feeds (#156)
elliotBraem May 19, 2025
cdf9a14
Leaderboard width fixes
saadiqbal-dev May 19, 2025
6c226e0
feat: save profile image to pinata (#158)
dungpt99 May 20, 2025
b77d92d
Feat Integrate NEAR Solana, Ethereum wallet selection (#159)
louisdevzz May 22, 2025
35bb5eb
Feed Submission + Feed Review Page (#160)
saadiqbal-dev May 22, 2025
1f54369
minor fixes (#164)
saadiqbal-dev May 26, 2025
673b884
remove node-compile-cache
elliotBraem May 29, 2025
8dffc5b
reuse user menu
elliotBraem May 29, 2025
90a4907
header clean up
elliotBraem May 29, 2025
953a1a1
remove how it works
elliotBraem May 29, 2025
a91789e
clean up
elliotBraem May 29, 2025
a6a603e
set submissions at root route
elliotBraem May 29, 2025
5661f48
fmt
elliotBraem May 29, 2025
bd386b0
clean
elliotBraem May 30, 2025
bea8fc5
create is coming soon
elliotBraem May 30, 2025
05de617
clean up
elliotBraem May 30, 2025
b6eea32
user link
elliotBraem May 30, 2025
d1a5f05
Adds caddyfile and frontend clean up (#165)
elliotBraem May 31, 2025
c11ad4e
pnpm lock
elliotBraem May 31, 2025
5dc24a7
fix turbo
elliotBraem May 31, 2025
d308427
fix build
elliotBraem May 31, 2025
6cec47b
db migration
elliotBraem May 31, 2025
8bc9534
without time zone
elliotBraem May 31, 2025
440f0b9
cleans up submission list
elliotBraem May 31, 2025
3631332
Adds shared-db, types package, initial migration (#166)
elliotBraem May 31, 2025
ea685af
update dockerfile
elliotBraem May 31, 2025
a84130e
monorepo
elliotBraem May 31, 2025
3f172eb
working build
elliotBraem May 31, 2025
34cabca
migration service
elliotBraem May 31, 2025
4444954
turbo
elliotBraem May 31, 2025
ac49a25
install pnpm
elliotBraem May 31, 2025
81d5b5b
temp proxy
elliotBraem May 31, 2025
c29851e
no include request headers
elliotBraem May 31, 2025
f05cf7c
clean up
elliotBraem May 31, 2025
8a4e270
proper path
elliotBraem May 31, 2025
74272ba
renaming
elliotBraem May 31, 2025
45d4011
fmt
elliotBraem May 31, 2025
fc74fcb
update caddyfile
elliotBraem Jun 2, 2025
4627a5a
different strategy
elliotBraem Jun 2, 2025
bc58f56
use route
elliotBraem Jun 2, 2025
61dfe9d
fix BACKEND to API
elliotBraem Jun 2, 2025
b8d8e81
ignore temp
elliotBraem Jun 2, 2025
a74187c
temp remove
elliotBraem Jun 2, 2025
60e0e7c
back to orig
elliotBraem Jun 2, 2025
738db5e
turn on auto https
elliotBraem Jun 2, 2025
f9c074a
disable
elliotBraem Jun 2, 2025
7099891
route block
elliotBraem Jun 2, 2025
84eab5c
clean up
elliotBraem Jun 2, 2025
a63b225
configure host
elliotBraem Jun 2, 2025
20c1f28
favicon
elliotBraem Jun 2, 2025
60ef915
add staging domain
elliotBraem Jun 2, 2025
a95fa06
http:
elliotBraem Jun 2, 2025
51440bc
set domain adn host
elliotBraem Jun 2, 2025
82a829d
correct bash
elliotBraem Jun 2, 2025
c03a909
matching host
elliotBraem Jun 2, 2025
45eadff
Adds edit feed and image upload (#168)
elliotBraem Jun 3, 2025
98f8d58
CSR
elliotBraem Jun 3, 2025
fdc1e5d
vercel json
elliotBraem Jun 3, 2025
9c74b63
move
elliotBraem Jun 3, 2025
d99c49b
temp disable auth
elliotBraem Jun 3, 2025
7e8c95e
set image
elliotBraem Jun 3, 2025
97d9c95
fix query
elliotBraem Jun 3, 2025
18a9943
submisison service running
elliotBraem Jun 4, 2025
79b31f9
Migrates submission service, is running (#169)
elliotBraem Jun 4, 2025
b704abd
fix config path
elliotBraem Jun 4, 2025
9eef768
adds plugins route and integrates with plugin service
elliotBraem Jun 4, 2025
c445a81
remote curate.config.json
elliotBraem Jun 4, 2025
50f935f
plugins table
elliotBraem Jun 4, 2025
ef316a8
adds plugin pages
elliotBraem Jun 5, 2025
6edaccb
set type
elliotBraem Jun 5, 2025
60c49e6
fix feed types
elliotBraem Jun 5, 2025
1013e80
plguin errors
elliotBraem Jun 5, 2025
24764f7
env injection
elliotBraem Jun 7, 2025
6003d2e
fix queries
elliotBraem Jun 9, 2025
c2ab5f4
fix migration
elliotBraem Jun 9, 2025
c2bc5b5
fix migration
elliotBraem Jun 9, 2025
05dea50
fix migration
elliotBraem Jun 9, 2025
a10bb98
redo migration
elliotBraem Jun 9, 2025
45a5d2f
decouples moderation
elliotBraem Jun 9, 2025
3475cf5
fix status
elliotBraem Jun 10, 2025
dc6ea46
fix feeds
elliotBraem Jun 10, 2025
8c60522
hide moderation actions
elliotBraem Jun 10, 2025
c53a8d7
Merge branch 'main' of https://github.com/PotLock/curatedotfun into s…
elliotBraem Jun 10, 2025
a328e69
adds overwrite script
elliotBraem Jun 10, 2025
8bc0213
migrate timestamps
elliotBraem Jun 10, 2025
f05166f
Merge branch 'feat/overwrite-script' into staging
elliotBraem Jun 10, 2025
11c43dc
better date handling
elliotBraem Jun 10, 2025
a0cbdf0
fix
elliotBraem Jun 10, 2025
79f280d
Merge branch 'main' into staging
elliotBraem Jun 10, 2025
afe8e25
init secret service
elliotBraem Jun 11, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/api/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ export const env = createEnv({
XPOSTBOUNTY1_RSS_API_SECRET: z.string().optional(),
AFROBEATS_RSS_API_SECRET: z.string().optional(),
AFRICA_RSS_API_SECRET: z.string().optional(),

SECRET_SERVICE_URL: z
.string()
.url({ message: "SECRET_SERVICE_URL must be a valid URL" }),
SECRET_SERVICE_INTERNAL_API_KEY: z
.string()
.min(1, { message: "SECRET_SERVICE_INTERNAL_API_KEY must be defined" }),
},

runtimeEnv: process.env,
Expand Down
4 changes: 3 additions & 1 deletion apps/api/src/services/distribution.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { DistributorConfig, RichSubmission } from "@curatedotfun/shared-db";
import type { ActionArgs } from "@curatedotfun/types";
import { PluginError, PluginErrorCode } from "@curatedotfun/utils";
import type { Logger } from "pino";
import { isStaging } from "./config.service";
import { logPluginError } from "../utils/error";
import { sanitizeJson } from "../utils/sanitize";
import { isStaging } from "./config.service";
import type { IBaseService } from "./interfaces/base-service.interface";
import { PluginService } from "./plugin.service";

Expand All @@ -21,6 +21,7 @@ export class DistributionService implements IBaseService {
async distributeContent<T = RichSubmission>(
distributor: DistributorConfig,
input: T,
feedId: string,
): Promise<void> {
const sanitizedInput = sanitizeJson(input) as T;

Expand All @@ -32,6 +33,7 @@ export class DistributionService implements IBaseService {
type: "distributor",
config: pluginConfig || {},
},
feedId,
);

try {
Expand Down
7 changes: 3 additions & 4 deletions apps/api/src/services/feed.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,10 @@ export class FeedService implements IBaseService {
{ feedId },
"FeedService: processFeed - Feed not found",
);
throw new Error(`Feed not found: ${feedId}`); // Or a custom NotFoundError
throw new Error(`Feed not found: ${feedId}`);
}

const feedConfig = await this.feedRepository.getFeedConfig(feedId); // Get config from DB
const feedConfig = await this.feedRepository.getFeedConfig(feedId);
if (!feedConfig) {
this.logger.error(
{ feedId },
Expand Down Expand Up @@ -154,14 +154,13 @@ export class FeedService implements IBaseService {
}
}

await this.processorService.process(submission, streamConfig);
await this.processorService.process(submission, streamConfig, feedId);
processedCount++;
} catch (error) {
this.logger.error(
{ error, submissionId: submission.tweetId, feedId },
`Error processing submission ${submission.tweetId} for feed ${feedId}`,
);
// Decide if one error should stop all processing or just skip this one
}
}

Expand Down
105 changes: 71 additions & 34 deletions apps/api/src/services/plugin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,14 @@ import { PluginError, PluginErrorCode } from "@curatedotfun/utils";
import { performReload } from "@module-federation/node/utils";
import { init, loadRemote } from "@module-federation/runtime";
import type { Logger } from "pino";
import Mustache from "mustache";
import { PluginConfig } from "types/config";
import { env } from "../env";
import { db } from "../db";
import { logPluginError } from "../utils/error";
import { logger } from "../utils/logger";
import { createPluginInstanceKey } from "../utils/plugin";
import { isProduction } from "./config.service";
import { IBaseService } from "./interfaces/base-service.interface";
import { SecretServiceApiClient } from "./secret-service-client";

/**
* Cache entry for a loaded plugin
Expand Down Expand Up @@ -64,8 +63,8 @@ type PluginContainer<
TConfig extends Record<string, unknown> = Record<string, unknown>,
> =
| {
default?: new () => PluginTypeMap<TInput, TOutput, TConfig>[T];
}
default?: new () => PluginTypeMap<TInput, TOutput, TConfig>[T];
}
| (new () => PluginTypeMap<TInput, TOutput, TConfig>[T]);

/**
Expand All @@ -76,6 +75,7 @@ export class PluginService implements IBaseService {
private remotes: Map<string, RemoteState> = new Map();
private instances: Map<string, InstanceState<PluginType>> = new Map();
private pluginRepository: PluginRepository;
private secretServiceApiClient: SecretServiceApiClient;

// Time in milliseconds before cached items are considered stale
private readonly instanceCacheTimeout: number = 7 * 24 * 60 * 60 * 1000; // 7 days (instance of a plugin with config)
Expand All @@ -86,9 +86,10 @@ export class PluginService implements IBaseService {
private readonly retryDelays: number[] = [1000, 5000]; // Delays between retries in ms

public readonly logger: Logger;
constructor(logger: Logger) {
constructor(logger: Logger, secretServiceApiClient: SecretServiceApiClient) {
this.logger = logger;
this.pluginRepository = new PluginRepository(db);
this.secretServiceApiClient = secretServiceApiClient;
}

/**
Expand All @@ -102,9 +103,9 @@ export class PluginService implements IBaseService {
>(
name: string,
pluginConfig: { type: T; config: TConfig },
feedId: string,
): Promise<PluginTypeMap<TInput, TOutput, TConfig>[T]> {
try {
// Get plugin metadata from database
const registeredPlugin =
await this.pluginRepository.getPluginByName(name);

Expand Down Expand Up @@ -144,7 +145,6 @@ export class PluginService implements IBaseService {
throw error;
}

// Create full config with URL from database
const config: PluginConfig<T> = {
type: pluginConfig.type,
url: registeredPlugin.entryPoint,
Expand Down Expand Up @@ -190,7 +190,6 @@ export class PluginService implements IBaseService {
this.remotes.set(normalizedName, remote);
}

// Create and initialize instance with retries
let lastError: Error | null = null;
for (let attempt = 0; attempt <= this.retryDelays.length; attempt++) {
try {
Expand All @@ -215,24 +214,62 @@ export class PluginService implements IBaseService {
TConfig
>[T];

// Hydrate config with environment variables
const stringifiedConfig = JSON.stringify(config.config);
const populatedConfigString = Mustache.render(stringifiedConfig, env); // TODO: Whitelist values
const hydratedConfig = JSON.parse(populatedConfigString) as TConfig;
this.logger.debug(
`PluginService: Preparing to hydrate config for plugin '${name}', feedId '${feedId}'.`,
);

let processedConfig: TConfig = JSON.parse(
JSON.stringify(config.config),
);

// --- Secret Hydration Logic ---
for (const key in processedConfig) {
if (Object.prototype.hasOwnProperty.call(processedConfig, key)) {
const value = processedConfig[key];

if (
typeof value === "string" &&
value.startsWith("{{") &&
value.endsWith("}}")
) {
const varName = value.substring(2, value.length - 2).trim(); // e.g., "OPENROUTER_API_KEY"

this.logger.debug(
`PluginService: Found placeholder '${value}' for key '${key}' in plugin '${name}', feedId '${feedId}'. Attempting to resolve.`,
);

const secret =
await this.secretServiceApiClient.getPlaintextSecret(
feedId,
varName,
);

if (secret !== null) {
(processedConfig as any)[key] = secret;
this.logger.info(
`PluginService: Hydrated config key '${key}' for plugin '${name}' (feedId '${feedId}') from secret-service for varName '${varName}'.`,
);
} else {
this.logger.warn(
`PluginService: Config key '${key}' for plugin '${name}' (feedId '${feedId}') references unknown secret/env var '${varName}'. Placeholder '${value}' remains.`,
);
// Optionally, throw an error if a required secret is missing:
throw new PluginError(`Required secret '${varName}' not found for plugin '${name}'`,
{
pluginName: name, operation: "hydrate"
}, PluginErrorCode.PLUGIN_INITIALIZATION_FAILED
);
}
}
}
}
Comment on lines +225 to +265
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add support for nested configuration objects.

The current implementation only handles placeholders in top-level config keys. Nested objects with placeholders won't be hydrated.

Consider implementing recursive hydration to support nested configurations:

private async hydrateConfig<TConfig>(
  config: TConfig,
  feedId: string,
  path: string = ''
): Promise<TConfig> {
  if (typeof config !== 'object' || config === null) {
    return config;
  }

  const hydrated = Array.isArray(config) ? [...config] : { ...config };

  for (const key in hydrated) {
    if (Object.prototype.hasOwnProperty.call(hydrated, key)) {
      const value = hydrated[key];
      const currentPath = path ? `${path}.${key}` : key;

      if (
        typeof value === 'string' &&
        value.startsWith('{{') &&
        value.endsWith('}}')
      ) {
        const varName = value.substring(2, value.length - 2).trim();
        const secret = await this.secretServiceApiClient.getPlaintextSecret(
          feedId,
          varName
        );

        if (secret !== null) {
          (hydrated as any)[key] = secret;
          this.logger.info(
            `Hydrated config at '${currentPath}' for plugin '${name}'`
          );
        } else {
          throw new PluginError(
            `Required secret '${varName}' not found`,
            { pluginName: name, operation: 'hydrate' },
            PluginErrorCode.PLUGIN_INITIALIZATION_FAILED
          );
        }
      } else if (typeof value === 'object' && value !== null) {
        (hydrated as any)[key] = await this.hydrateConfig(value, feedId, currentPath);
      }
    }
  }

  return hydrated as TConfig;
}

Then replace lines 221-265 with:

const processedConfig = await this.hydrateConfig(config.config, feedId);
🤖 Prompt for AI Agents
In apps/api/src/services/plugin.service.ts around lines 225 to 265, the current
secret hydration logic only processes top-level config keys and does not handle
nested objects containing placeholders. To fix this, implement a recursive async
method that traverses the entire config object, detects placeholders at any
depth, and replaces them with secrets from secretServiceApiClient. Replace the
existing loop with a call to this new recursive method to ensure all nested
placeholders are hydrated properly.


await newInstance.initialize(hydratedConfig);
this.logger.debug(
`PluginService: Config hydration complete for plugin '${name}', feedId '${feedId}'.`,
);

// // Validate instance implements required interface
// if (!this.validatePluginInterface<T, TInput, TOutput, TConfig>(newInstance, config.type)) {
// throw new PluginInitError(
// name,
// new Error(
// `Plugin does not implement required ${config.type} interface`,
// ),
// );
// }
await newInstance.initialize(processedConfig);

// Cache successful instance
const instanceState: InstanceState<T> = {
instance: newInstance as PluginTypeMap<
unknown,
Expand Down Expand Up @@ -302,17 +339,17 @@ export class PluginService implements IBaseService {
throw error instanceof PluginError
? error
: new PluginError(
`Unexpected error with plugin ${name}`,
{
pluginName: name,
operation: "unknown",
},
PluginErrorCode.UNKNOWN_PLUGIN_ERROR,
false,
{
cause: error instanceof Error ? error : undefined,
},
);
`Unexpected error with plugin ${name}`,
{
pluginName: name,
operation: "unknown",
},
PluginErrorCode.UNKNOWN_PLUGIN_ERROR,
false,
{
cause: error instanceof Error ? error : undefined,
},
);
}
}

Expand Down
21 changes: 16 additions & 5 deletions apps/api/src/services/processor.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import { logger } from "../utils/logger";
import { sanitizeJson } from "../utils/sanitize";
import { DistributionService } from "./distribution.service";
import { IBaseService } from "./interfaces/base-service.interface";
import { TransformationService } from "./transformation.service";
import { TransformationService } from "./transformation.service.js";

interface ProcessConfig {
export interface ProcessConfig {
enabled?: boolean;
transform?: TransformConfig[];
distribute?: DistributorConfig[];
Expand All @@ -30,9 +30,12 @@ export class ProcessorService implements IBaseService {

/**
* Process content through transformation pipeline and distribute
* Can be used for both individual submissions and bulk content (like recaps)
*/
async process(content: RichSubmission, config: ProcessConfig) {
async process(
content: RichSubmission,
config: ProcessConfig,
feedId: string,
) {
try {
// Apply global transforms if any
let processed = content;
Expand All @@ -42,6 +45,7 @@ export class ProcessorService implements IBaseService {
processed,
config.transform,
"global",
feedId,
);

processed = sanitizeJson(processed);
Expand Down Expand Up @@ -75,6 +79,7 @@ export class ProcessorService implements IBaseService {
distributorContent,
distributor.transform,
"distributor",
feedId,
);
distributorContent = sanitizeJson(distributorContent);
} catch (error) {
Expand All @@ -95,6 +100,7 @@ export class ProcessorService implements IBaseService {
await this.distributionService.distributeContent(
distributor,
distributorContent,
feedId,
);
} catch (error) {
// Collect errors but continue with other distributors
Expand Down Expand Up @@ -132,8 +138,9 @@ export class ProcessorService implements IBaseService {
async processBatch(
items: any[],
config: ProcessConfig & {
batchTransform?: TransformConfig[]; // Optional transforms to apply to collected results
batchTransform?: TransformConfig[];
},
feedId: string,
) {
try {
// Process each item through global and distributor transforms
Expand All @@ -147,6 +154,7 @@ export class ProcessorService implements IBaseService {
processed,
config.transform,
"global",
feedId,
);

processed = sanitizeJson(processed);
Expand All @@ -168,6 +176,7 @@ export class ProcessorService implements IBaseService {
results,
config.batchTransform,
"batch",
feedId,
);

batchResult = sanitizeJson(batchResult);
Expand Down Expand Up @@ -197,6 +206,7 @@ export class ProcessorService implements IBaseService {
distributorContent,
distributor.transform,
"distributor",
feedId,
);

distributorContent = sanitizeJson(distributorContent);
Expand All @@ -206,6 +216,7 @@ export class ProcessorService implements IBaseService {
await this.distributionService.distributeContent(
distributor,
distributorContent,
feedId,
);
} catch (error) {
errors.push(
Expand Down
Loading
Loading